orkestr-cli 0.1.0__py3-none-any.whl

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.
File without changes
orkestr_cli/api.py ADDED
@@ -0,0 +1,195 @@
1
+ """HTTP client for the Orkestr API."""
2
+
3
+ import sys
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from orkestr_cli import config
9
+
10
+
11
+ def _client() -> httpx.Client:
12
+ headers = config.get_auth_header()
13
+ return httpx.Client(
14
+ base_url=config.get_api_url(),
15
+ headers=headers,
16
+ timeout=30.0,
17
+ )
18
+
19
+
20
+ def _request(method: str, path: str, **kwargs) -> httpx.Response:
21
+ """Execute a request with connection error handling."""
22
+ try:
23
+ with _client() as c:
24
+ return getattr(c, method)(path, **kwargs)
25
+ except httpx.ConnectError:
26
+ url = config.get_api_url()
27
+ click.secho(f"Connection refused: {url}", fg="red", err=True)
28
+ click.secho("Is the API running? Check with: orkestr set-url <url>", err=True)
29
+ sys.exit(1)
30
+ except httpx.TimeoutException:
31
+ click.secho("Request timed out.", fg="red", err=True)
32
+ sys.exit(1)
33
+ except httpx.HTTPError as e:
34
+ click.secho(f"HTTP error: {e}", fg="red", err=True)
35
+ sys.exit(1)
36
+
37
+
38
+ def _handle(resp: httpx.Response) -> dict | list | None:
39
+ if resp.status_code == 204:
40
+ return None
41
+ if resp.status_code == 401:
42
+ click.secho("Authentication required. Run: orkestr login <token>", fg="red", err=True)
43
+ sys.exit(1)
44
+ if resp.status_code >= 400:
45
+ try:
46
+ detail = resp.json().get("detail", resp.text)
47
+ except Exception:
48
+ detail = resp.text
49
+ click.secho(f"Error ({resp.status_code}): {detail}", fg="red", err=True)
50
+ sys.exit(1)
51
+ return resp.json()
52
+
53
+
54
+ def _get(path: str, **kwargs):
55
+ return _handle(_request("get", path, **kwargs))
56
+
57
+
58
+ def _post(path: str, **kwargs):
59
+ return _handle(_request("post", path, **kwargs))
60
+
61
+
62
+ def _patch(path: str, **kwargs):
63
+ return _handle(_request("patch", path, **kwargs))
64
+
65
+
66
+ def _delete(path: str, **kwargs):
67
+ return _handle(_request("delete", path, **kwargs))
68
+
69
+
70
+ # -- Auth -------------------------------------------------------------------
71
+
72
+ def me() -> dict:
73
+ return _get("/api/auth/me")
74
+
75
+
76
+ # -- Helpers ----------------------------------------------------------------
77
+
78
+ def resolve_project_id(name_or_id: str) -> str:
79
+ """Accept a project name or UUID, return the full UUID."""
80
+ # Already looks like a UUID
81
+ if len(name_or_id) >= 32 or name_or_id.count("-") >= 4:
82
+ return name_or_id
83
+ # Search by name
84
+ data = list_projects(limit=100)
85
+ for p in data.get("projects", []):
86
+ if p.get("name") == name_or_id:
87
+ return p["id"]
88
+ click.secho(f"Project not found: {name_or_id}", fg="red", err=True)
89
+ sys.exit(1)
90
+
91
+
92
+ # -- Projects ---------------------------------------------------------------
93
+
94
+ def list_projects(skip: int = 0, limit: int = 20) -> dict:
95
+ return _get("/api/v1/projects", params={"skip": skip, "limit": limit})
96
+
97
+
98
+ def create_project(name: str, repo_url: str, branch: str = "main",
99
+ display_name: str | None = None, description: str | None = None,
100
+ env_vars: dict | None = None) -> dict:
101
+ payload = {"name": name, "repo_url": repo_url, "repo_branch": branch}
102
+ if display_name:
103
+ payload["display_name"] = display_name
104
+ if description:
105
+ payload["description"] = description
106
+ if env_vars:
107
+ payload["env_vars"] = env_vars
108
+ return _post("/api/v1/projects", json=payload)
109
+
110
+
111
+ def get_project(project_id: str) -> dict:
112
+ return _get(f"/api/v1/projects/{project_id}")
113
+
114
+
115
+ def update_project(project_id: str, **fields) -> dict:
116
+ return _patch(f"/api/v1/projects/{project_id}", json=fields)
117
+
118
+
119
+ def delete_project(project_id: str):
120
+ _delete(f"/api/v1/projects/{project_id}")
121
+
122
+
123
+ def analyze_project(project_id: str) -> dict:
124
+ return _post(f"/api/v1/projects/{project_id}/analyze")
125
+
126
+
127
+ # -- Environments -----------------------------------------------------------
128
+
129
+ def list_environments(project_id: str) -> list:
130
+ return _get(f"/api/v1/projects/{project_id}/environments")
131
+
132
+
133
+ def create_environment(project_id: str, name: str, branch: str,
134
+ env_vars: dict | None = None, auto_deploy: bool = True) -> dict:
135
+ payload = {"name": name, "branch": branch, "auto_deploy": auto_deploy}
136
+ if env_vars:
137
+ payload["env_vars"] = env_vars
138
+ return _post(f"/api/v1/projects/{project_id}/environments", json=payload)
139
+
140
+
141
+ def update_environment(project_id: str, env_id: str, **fields) -> dict:
142
+ return _patch(f"/api/v1/projects/{project_id}/environments/{env_id}", json=fields)
143
+
144
+
145
+ def delete_environment(project_id: str, env_id: str):
146
+ _delete(f"/api/v1/projects/{project_id}/environments/{env_id}")
147
+
148
+
149
+ # -- Deployments ------------------------------------------------------------
150
+
151
+ def trigger_deployment(project_id: str, environment_id: str | None = None) -> dict:
152
+ params = {}
153
+ if environment_id:
154
+ params["environment_id"] = environment_id
155
+ return _post(f"/api/v1/projects/{project_id}/deployments", params=params)
156
+
157
+
158
+ def list_deployments(project_id: str, skip: int = 0, limit: int = 20) -> dict:
159
+ return _get(f"/api/v1/projects/{project_id}/deployments",
160
+ params={"skip": skip, "limit": limit})
161
+
162
+
163
+ def get_deployment(deployment_id: str) -> dict:
164
+ return _get(f"/api/v1/deployments/{deployment_id}")
165
+
166
+
167
+ def get_deployment_logs(deployment_id: str) -> dict:
168
+ return _get(f"/api/v1/deployments/{deployment_id}/logs")
169
+
170
+
171
+ def rollback_deployment(deployment_id: str) -> dict:
172
+ return _post(f"/api/v1/deployments/{deployment_id}/rollback")
173
+
174
+
175
+ # -- Monitoring -------------------------------------------------------------
176
+
177
+ def get_stats(project_id: str, environment_id: str | None = None) -> dict:
178
+ params = {}
179
+ if environment_id:
180
+ params["environment_id"] = environment_id
181
+ return _get(f"/api/v1/projects/{project_id}/stats", params=params)
182
+
183
+
184
+ def get_logs(project_id: str, environment_id: str | None = None, lines: int = 200) -> dict:
185
+ params = {"lines": lines}
186
+ if environment_id:
187
+ params["environment_id"] = environment_id
188
+ return _get(f"/api/v1/projects/{project_id}/logs", params=params)
189
+
190
+
191
+ def get_health(project_id: str, environment_id: str | None = None) -> dict:
192
+ params = {}
193
+ if environment_id:
194
+ params["environment_id"] = environment_id
195
+ return _get(f"/api/v1/projects/{project_id}/health", params=params)
orkestr_cli/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """Configuration and credential storage for the Orkestr CLI."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".orkestr"
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+
9
+ DEFAULTS = {
10
+ "api_url": "https://api.orkestr.eu",
11
+ }
12
+
13
+
14
+ def _ensure_dir():
15
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
16
+ CONFIG_FILE.touch(exist_ok=True)
17
+
18
+
19
+ def load() -> dict:
20
+ _ensure_dir()
21
+ text = CONFIG_FILE.read_text().strip()
22
+ if not text:
23
+ return dict(DEFAULTS)
24
+ data = json.loads(text)
25
+ for k, v in DEFAULTS.items():
26
+ data.setdefault(k, v)
27
+ # Migrate old localhost default to production URL
28
+ if data.get("api_url") == "http://localhost:8000":
29
+ data["api_url"] = DEFAULTS["api_url"]
30
+ return data
31
+
32
+
33
+ def save(data: dict):
34
+ _ensure_dir()
35
+ CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
36
+
37
+
38
+ def get_api_url() -> str:
39
+ return load().get("api_url", DEFAULTS["api_url"])
40
+
41
+
42
+ def get_auth_header() -> dict[str, str]:
43
+ """Return the Authorization header dict, or empty dict if not logged in."""
44
+ cfg = load()
45
+ token = cfg.get("api_token") or cfg.get("access_token")
46
+ if not token:
47
+ return {}
48
+ prefix = "Bearer" if cfg.get("access_token") else "Bearer"
49
+ return {"Authorization": f"{prefix} {token}"}
50
+
51
+
52
+ def store_tokens(access_token: str, refresh_token: str | None = None):
53
+ cfg = load()
54
+ cfg["access_token"] = access_token
55
+ if refresh_token:
56
+ cfg["refresh_token"] = refresh_token
57
+ cfg.pop("api_token", None)
58
+ save(cfg)
59
+
60
+
61
+ def store_api_token(token: str):
62
+ cfg = load()
63
+ cfg["api_token"] = token
64
+ cfg.pop("access_token", None)
65
+ cfg.pop("refresh_token", None)
66
+ save(cfg)
67
+
68
+
69
+ def clear_auth():
70
+ cfg = load()
71
+ cfg.pop("access_token", None)
72
+ cfg.pop("refresh_token", None)
73
+ cfg.pop("api_token", None)
74
+ save(cfg)
75
+
76
+
77
+ def is_logged_in() -> bool:
78
+ cfg = load()
79
+ return bool(cfg.get("access_token") or cfg.get("api_token"))
orkestr_cli/main.py ADDED
@@ -0,0 +1,511 @@
1
+ """Orkestr CLI — manage your projects, deployments, and infrastructure."""
2
+
3
+ import click
4
+
5
+ from orkestr_cli import api, config
6
+ from orkestr_cli.output import (
7
+ console,
8
+ format_dt,
9
+ print_json,
10
+ print_kv,
11
+ print_table,
12
+ status_color,
13
+ truncate,
14
+ )
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Root group
19
+ # ---------------------------------------------------------------------------
20
+
21
+ @click.group()
22
+ @click.option("--api-url", envvar="ORKESTR_API_URL", help="Override API base URL.")
23
+ @click.option("--token", envvar="ORKESTR_API_TOKEN", help="API token for auth.")
24
+ @click.option("--json", "use_json", is_flag=True, help="Output raw JSON.")
25
+ @click.version_option("0.1.0", prog_name="orkestr")
26
+ @click.pass_context
27
+ def cli(ctx, api_url, token, use_json):
28
+ """Orkestr CLI — deploy and manage your applications."""
29
+ ctx.ensure_object(dict)
30
+ ctx.obj["json"] = use_json
31
+
32
+ if api_url:
33
+ cfg = config.load()
34
+ cfg["api_url"] = api_url
35
+ config.save(cfg)
36
+
37
+ if token:
38
+ config.store_api_token(token)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Auth commands
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @cli.command()
46
+ @click.option("--token", prompt="API token", hide_input=True, prompt_required=True,
47
+ help="API token (will be prompted if not provided).")
48
+ def login(token):
49
+ """Authenticate with an API token.
50
+
51
+ Generate a token from the Orkestr dashboard under Settings > API Tokens.
52
+ """
53
+ token = token.strip()
54
+ if not token:
55
+ click.secho("Token cannot be empty.", fg="red", err=True)
56
+ raise SystemExit(1)
57
+ config.store_api_token(token)
58
+ data = api.me()
59
+ click.secho(f"Logged in as {data.get('username', 'unknown')}.", fg="green")
60
+
61
+
62
+ @cli.command()
63
+ def logout():
64
+ """Clear stored credentials."""
65
+ config.clear_auth()
66
+ click.secho("Logged out.", fg="green")
67
+
68
+
69
+ @cli.command()
70
+ @click.pass_context
71
+ def whoami(ctx):
72
+ """Show current user info."""
73
+ data = api.me()
74
+ if ctx.obj["json"]:
75
+ print_json(data)
76
+ return
77
+ print_kv([
78
+ ("Username:", data.get("username", "-")),
79
+ ("Email:", data.get("email", "-")),
80
+ ("Plan:", data.get("plan", "-")),
81
+ ("Active:", str(data.get("is_active", "-"))),
82
+ ("Created:", format_dt(data.get("created_at"))),
83
+ ])
84
+
85
+
86
+ @cli.command("set-url")
87
+ @click.argument("url")
88
+ def set_url(url):
89
+ """Set the API base URL."""
90
+ cfg = config.load()
91
+ cfg["api_url"] = url.rstrip("/")
92
+ config.save(cfg)
93
+ click.secho(f"API URL set to {cfg['api_url']}", fg="green")
94
+
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Projects
99
+ # ---------------------------------------------------------------------------
100
+
101
+ @cli.group()
102
+ def projects():
103
+ """Manage projects."""
104
+
105
+
106
+ @projects.command("list")
107
+ @click.option("--limit", default=20, help="Max results.")
108
+ @click.pass_context
109
+ def projects_list(ctx, limit):
110
+ """List all projects."""
111
+ data = api.list_projects(limit=limit)
112
+ if ctx.obj["json"]:
113
+ print_json(data)
114
+ return
115
+
116
+ rows = []
117
+ for p in data.get("projects", []):
118
+ rows.append([
119
+ p.get("name", "-"),
120
+ p.get("display_name") or p.get("name", "-"),
121
+ p.get("framework") or "-",
122
+ status_color(p.get("status", "-")),
123
+ format_dt(p.get("created_at")),
124
+ ])
125
+ print_table("Projects", ["Name", "Display Name", "Framework", "Status", "Created"], rows)
126
+ click.echo(f"\nTotal: {data.get('total', len(rows))}")
127
+
128
+
129
+ @projects.command("create")
130
+ @click.argument("name")
131
+ @click.option("--repo", required=True, help="Git repository URL.")
132
+ @click.option("--branch", default="main", help="Default branch.")
133
+ @click.option("--display-name", help="Display name.")
134
+ @click.option("--description", help="Project description.")
135
+ @click.pass_context
136
+ def projects_create(ctx, name, repo, branch, display_name, description):
137
+ """Create a new project."""
138
+ data = api.create_project(
139
+ name=name,
140
+ repo_url=repo,
141
+ branch=branch,
142
+ display_name=display_name,
143
+ description=description,
144
+ )
145
+ if ctx.obj["json"]:
146
+ print_json(data)
147
+ return
148
+ click.secho(f"Project '{data['name']}' created.", fg="green")
149
+ print_kv([
150
+ ("ID:", data["id"]),
151
+ ("Domain:", data.get("domain", "-")),
152
+ ])
153
+
154
+
155
+ @projects.command("show")
156
+ @click.argument("project")
157
+ @click.pass_context
158
+ def projects_show(ctx, project):
159
+ """Show project details. Accepts a project name or UUID."""
160
+ project_id = api.resolve_project_id(project)
161
+ data = api.get_project(project_id)
162
+ if ctx.obj["json"]:
163
+ print_json(data)
164
+ return
165
+ print_kv([
166
+ ("ID:", data["id"]),
167
+ ("Name:", data.get("name", "-")),
168
+ ("Display Name:", data.get("display_name") or "-"),
169
+ ("Repo:", data.get("repo_url", "-")),
170
+ ("Branch:", data.get("repo_branch", "-")),
171
+ ("Framework:", data.get("framework") or "-"),
172
+ ("Dockerfile:", str(data.get("has_dockerfile", "-"))),
173
+ ("Status:", data.get("status", "-")),
174
+ ("Domain:", data.get("domain") or "-"),
175
+ ("Created:", format_dt(data.get("created_at"))),
176
+ ])
177
+
178
+
179
+ @projects.command("delete")
180
+ @click.argument("project")
181
+ @click.confirmation_option(prompt="Are you sure you want to delete this project?")
182
+ def projects_delete(project):
183
+ """Delete a project and its infrastructure. Accepts a project name or UUID."""
184
+ project_id = api.resolve_project_id(project)
185
+ api.delete_project(project_id)
186
+ click.secho("Project deleted.", fg="green")
187
+
188
+
189
+ @projects.command("analyze")
190
+ @click.argument("project")
191
+ @click.pass_context
192
+ def projects_analyze(ctx, project):
193
+ """Analyze a project's repository. Accepts a project name or UUID."""
194
+ project_id = api.resolve_project_id(project)
195
+ data = api.analyze_project(project_id)
196
+ if ctx.obj["json"]:
197
+ print_json(data)
198
+ return
199
+ print_kv([
200
+ ("Framework:", data.get("framework", "-")),
201
+ ("Has Dockerfile:", str(data.get("has_dockerfile", "-"))),
202
+ ("Detected Port:", str(data.get("detected_port", "-"))),
203
+ ("Suggested CPU:", str(data.get("suggested_cpu", "-"))),
204
+ ("Suggested Memory:", data.get("suggested_memory", "-")),
205
+ ])
206
+
207
+
208
+ @projects.command("update")
209
+ @click.argument("project")
210
+ @click.option("--display-name", help="New display name.")
211
+ @click.option("--description", help="New description.")
212
+ @click.option("--branch", help="New default branch.")
213
+ @click.pass_context
214
+ def projects_update(ctx, project, display_name, description, branch):
215
+ """Update project settings. Accepts a project name or UUID."""
216
+ project_id = api.resolve_project_id(project)
217
+ fields = {}
218
+ if display_name is not None:
219
+ fields["display_name"] = display_name
220
+ if description is not None:
221
+ fields["description"] = description
222
+ if branch is not None:
223
+ fields["repo_branch"] = branch
224
+ if not fields:
225
+ click.secho("No fields to update.", fg="yellow")
226
+ return
227
+ data = api.update_project(project_id, **fields)
228
+ if ctx.obj["json"]:
229
+ print_json(data)
230
+ return
231
+ click.secho(f"Project '{data['name']}' updated.", fg="green")
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Environments
236
+ # ---------------------------------------------------------------------------
237
+
238
+ @cli.group("envs")
239
+ def envs():
240
+ """Manage environments."""
241
+
242
+
243
+ @envs.command("list")
244
+ @click.argument("project")
245
+ @click.pass_context
246
+ def envs_list(ctx, project):
247
+ """List environments for a project."""
248
+ project_id = api.resolve_project_id(project)
249
+ data = api.list_environments(project_id)
250
+ if ctx.obj["json"]:
251
+ print_json(data)
252
+ return
253
+
254
+ rows = []
255
+ for e in data:
256
+ rows.append([
257
+ e.get("name", "-"),
258
+ e.get("branch", "-"),
259
+ e.get("domain") or "-",
260
+ "Yes" if e.get("is_production") else "No",
261
+ "Yes" if e.get("auto_deploy") else "No",
262
+ status_color(e.get("status", "-")),
263
+ ])
264
+ print_table("Environments", ["Name", "Branch", "Domain", "Prod", "Auto Deploy", "Status"], rows)
265
+
266
+
267
+ @envs.command("create")
268
+ @click.argument("project")
269
+ @click.argument("name")
270
+ @click.option("--branch", required=True, help="Git branch.")
271
+ @click.option("--auto-deploy/--no-auto-deploy", default=True, help="Auto deploy on push.")
272
+ @click.pass_context
273
+ def envs_create(ctx, project, name, branch, auto_deploy):
274
+ """Create a new environment."""
275
+ project_id = api.resolve_project_id(project)
276
+ data = api.create_environment(project_id, name, branch, auto_deploy=auto_deploy)
277
+ if ctx.obj["json"]:
278
+ print_json(data)
279
+ return
280
+ click.secho(f"Environment '{data['name']}' created.", fg="green")
281
+ print_kv([
282
+ ("ID:", data["id"]),
283
+ ("Domain:", data.get("domain", "-")),
284
+ ])
285
+
286
+
287
+ @envs.command("update")
288
+ @click.argument("project")
289
+ @click.argument("env_id")
290
+ @click.option("--branch", help="New branch.")
291
+ @click.option("--auto-deploy/--no-auto-deploy", default=None, help="Auto deploy on push.")
292
+ @click.pass_context
293
+ def envs_update(ctx, project, env_id, branch, auto_deploy):
294
+ """Update an environment."""
295
+ project_id = api.resolve_project_id(project)
296
+ fields = {}
297
+ if branch is not None:
298
+ fields["branch"] = branch
299
+ if auto_deploy is not None:
300
+ fields["auto_deploy"] = auto_deploy
301
+ if not fields:
302
+ click.secho("No fields to update.", fg="yellow")
303
+ return
304
+ data = api.update_environment(project_id, env_id, **fields)
305
+ if ctx.obj["json"]:
306
+ print_json(data)
307
+ return
308
+ click.secho(f"Environment '{data['name']}' updated.", fg="green")
309
+
310
+
311
+ @envs.command("delete")
312
+ @click.argument("project")
313
+ @click.argument("env_id")
314
+ @click.confirmation_option(prompt="Are you sure you want to delete this environment?")
315
+ def envs_delete(project, env_id):
316
+ """Delete an environment."""
317
+ project_id = api.resolve_project_id(project)
318
+ api.delete_environment(project_id, env_id)
319
+ click.secho("Environment deleted.", fg="green")
320
+
321
+
322
+ # ---------------------------------------------------------------------------
323
+ # Deploy (shortcut)
324
+ # ---------------------------------------------------------------------------
325
+
326
+ @cli.command()
327
+ @click.argument("project")
328
+ @click.option("--env", "environment_id", help="Environment ID (defaults to production).")
329
+ @click.pass_context
330
+ def deploy(ctx, project, environment_id):
331
+ """Trigger a deployment."""
332
+ project_id = api.resolve_project_id(project)
333
+ data = api.trigger_deployment(project_id, environment_id)
334
+ if ctx.obj["json"]:
335
+ print_json(data)
336
+ return
337
+ click.secho("Deployment triggered.", fg="green")
338
+ print_kv([
339
+ ("Deployment ID:", data["id"]),
340
+ ("Status:", data.get("status", "-")),
341
+ ])
342
+ click.echo(f"\nTrack progress: orkestr deployments show {data['id']}")
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Deployments
347
+ # ---------------------------------------------------------------------------
348
+
349
+ @cli.group()
350
+ def deployments():
351
+ """Manage deployments."""
352
+
353
+
354
+ @deployments.command("list")
355
+ @click.argument("project")
356
+ @click.option("--limit", default=20, help="Max results.")
357
+ @click.pass_context
358
+ def deployments_list(ctx, project, limit):
359
+ """List deployments for a project."""
360
+ project_id = api.resolve_project_id(project)
361
+ data = api.list_deployments(project_id, limit=limit)
362
+ if ctx.obj["json"]:
363
+ print_json(data)
364
+ return
365
+
366
+ rows = []
367
+ for d in data.get("deployments", []):
368
+ rows.append([
369
+ d["id"][:8],
370
+ status_color(d.get("status", "-")),
371
+ d.get("trigger", "-"),
372
+ truncate(d.get("commit_sha") or "-", 8),
373
+ format_dt(d.get("created_at")),
374
+ ])
375
+ print_table("Deployments", ["ID", "Status", "Trigger", "Commit", "Created"], rows)
376
+ click.echo(f"\nTotal: {data.get('total', len(rows))}")
377
+
378
+
379
+ @deployments.command("show")
380
+ @click.argument("deployment_id")
381
+ @click.pass_context
382
+ def deployments_show(ctx, deployment_id):
383
+ """Show deployment details."""
384
+ data = api.get_deployment(deployment_id)
385
+ if ctx.obj["json"]:
386
+ print_json(data)
387
+ return
388
+ print_kv([
389
+ ("ID:", data["id"]),
390
+ ("Status:", data.get("status", "-")),
391
+ ("Trigger:", data.get("trigger", "-")),
392
+ ("Commit:", data.get("commit_sha") or "-"),
393
+ ("Image:", data.get("docker_image") or "-"),
394
+ ("Created:", format_dt(data.get("created_at"))),
395
+ ("Finished:", format_dt(data.get("finished_at"))),
396
+ ])
397
+
398
+ steps = data.get("steps", [])
399
+ if steps:
400
+ click.echo("\nPipeline Steps:")
401
+ for s in steps:
402
+ icon = {"completed": "[green]OK[/]", "failed": "[red]FAIL[/]", "running": "[yellow]...[/]"}.get(s.get("status"), " ")
403
+ console.print(f" {icon} {s.get('step_name', '-')}")
404
+
405
+
406
+ @deployments.command("logs")
407
+ @click.argument("deployment_id")
408
+ @click.pass_context
409
+ def deployments_logs(ctx, deployment_id):
410
+ """Show deployment pipeline logs."""
411
+ data = api.get_deployment_logs(deployment_id)
412
+ if ctx.obj["json"]:
413
+ print_json(data)
414
+ return
415
+
416
+ current_step = None
417
+ for entry in data.get("logs", []):
418
+ step = entry.get("step", "")
419
+ if step != current_step:
420
+ current_step = step
421
+ click.secho(f"\n--- {step} ---", fg="cyan", bold=True)
422
+ click.echo(entry.get("message", ""))
423
+
424
+
425
+ @deployments.command("rollback")
426
+ @click.argument("deployment_id")
427
+ @click.confirmation_option(prompt="Are you sure you want to rollback to this deployment?")
428
+ @click.pass_context
429
+ def deployments_rollback(ctx, deployment_id):
430
+ """Rollback to a previous deployment's image."""
431
+ data = api.rollback_deployment(deployment_id)
432
+ if ctx.obj["json"]:
433
+ print_json(data)
434
+ return
435
+ click.secho("Rollback deployment triggered.", fg="green")
436
+ print_kv([
437
+ ("Deployment ID:", data["id"]),
438
+ ("Status:", data.get("status", "-")),
439
+ ])
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Monitoring shortcuts
444
+ # ---------------------------------------------------------------------------
445
+
446
+ @cli.command()
447
+ @click.argument("project")
448
+ @click.option("--env", "environment_id", help="Environment ID.")
449
+ @click.option("--lines", default=200, help="Number of log lines.")
450
+ @click.pass_context
451
+ def logs(ctx, project, environment_id, lines):
452
+ """Show application runtime logs."""
453
+ project_id = api.resolve_project_id(project)
454
+ data = api.get_logs(project_id, environment_id, lines)
455
+ if ctx.obj["json"]:
456
+ print_json(data)
457
+ return
458
+
459
+ click.secho(f"Container: {data.get('container', '-')}", fg="cyan")
460
+ click.echo()
461
+ for line in data.get("logs", "").strip().split("\n"):
462
+ click.echo(line)
463
+
464
+
465
+ @cli.command()
466
+ @click.argument("project")
467
+ @click.option("--env", "environment_id", help="Environment ID.")
468
+ @click.pass_context
469
+ def stats(ctx, project, environment_id):
470
+ """Show container resource stats."""
471
+ project_id = api.resolve_project_id(project)
472
+ data = api.get_stats(project_id, environment_id)
473
+ if ctx.obj["json"]:
474
+ print_json(data)
475
+ return
476
+
477
+ print_kv([
478
+ ("CPU %:", str(data.get("cpu_percent", "-"))),
479
+ ("Memory:", f"{data.get('memory_usage', '-')} / {data.get('memory_limit', '-')}"),
480
+ ("Memory %:", str(data.get("memory_percent", "-"))),
481
+ ("Net I/O:", f"{data.get('network_rx', '-')} / {data.get('network_tx', '-')}"),
482
+ ])
483
+
484
+
485
+ @cli.command()
486
+ @click.argument("project")
487
+ @click.option("--env", "environment_id", help="Environment ID.")
488
+ @click.pass_context
489
+ def health(ctx, project, environment_id):
490
+ """Show container health info."""
491
+ project_id = api.resolve_project_id(project)
492
+ data = api.get_health(project_id, environment_id)
493
+ if ctx.obj["json"]:
494
+ print_json(data)
495
+ return
496
+
497
+ print_kv([
498
+ ("Status:", data.get("status", "-")),
499
+ ("Uptime:", data.get("uptime", "-")),
500
+ ("Restarts:", str(data.get("restart_count", "-"))),
501
+ ("Image:", data.get("image", "-")),
502
+ ("Started:", data.get("started_at", "-")),
503
+ ])
504
+
505
+
506
+ # ---------------------------------------------------------------------------
507
+ # Entry point
508
+ # ---------------------------------------------------------------------------
509
+
510
+ if __name__ == "__main__":
511
+ cli()
orkestr_cli/output.py ADDED
@@ -0,0 +1,59 @@
1
+ """Output formatting helpers using Rich."""
2
+
3
+ import json as json_mod
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ console = Console()
10
+
11
+
12
+ def print_json(data):
13
+ """Print raw JSON output."""
14
+ click.echo(json_mod.dumps(data, indent=2, default=str))
15
+
16
+
17
+ def print_table(title: str, columns: list[str], rows: list[list[str]]):
18
+ """Print a Rich table."""
19
+ table = Table(title=title, show_lines=False)
20
+ for col in columns:
21
+ table.add_column(col)
22
+ for row in rows:
23
+ table.add_row(*[str(c) for c in row])
24
+ console.print(table)
25
+
26
+
27
+ def print_kv(pairs: list[tuple[str, str]]):
28
+ """Print key-value pairs."""
29
+ max_key = max(len(k) for k, _ in pairs) if pairs else 0
30
+ for key, value in pairs:
31
+ click.echo(f" {key:<{max_key + 2}} {value}")
32
+
33
+
34
+ def truncate(text: str, length: int = 40) -> str:
35
+ if not text:
36
+ return "-"
37
+ return text[:length] + "..." if len(text) > length else text
38
+
39
+
40
+ def format_dt(dt_str: str | None) -> str:
41
+ if not dt_str:
42
+ return "-"
43
+ return dt_str[:19].replace("T", " ")
44
+
45
+
46
+ def status_color(status: str) -> str:
47
+ colors = {
48
+ "live": "green",
49
+ "running": "green",
50
+ "active": "green",
51
+ "building": "yellow",
52
+ "deploying": "yellow",
53
+ "queued": "cyan",
54
+ "failed": "red",
55
+ "deleted": "dim",
56
+ "stopped": "dim",
57
+ }
58
+ color = colors.get(status, "white")
59
+ return click.style(status, fg=color)
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: orkestr-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Orkestr Internal Developer Platform
5
+ Author-email: Orkestr <hello@orkestr.eu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://orkestr.eu
8
+ Project-URL: Documentation, https://orkestr.eu/docs
9
+ Project-URL: Repository, https://github.com/orkestr/cli
10
+ Keywords: orkestr,cli,deploy,paas,idp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: rich>=13.0
25
+
26
+ # orkestr-cli
27
+
28
+ CLI for the [Orkestr](https://orkestr.eu) Internal Developer Platform.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install orkestr-cli
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ # Authenticate with your API token
40
+ orkestr login
41
+
42
+ # List your projects
43
+ orkestr projects list
44
+
45
+ # Deploy
46
+ orkestr deploy my-api
47
+
48
+ # View logs
49
+ orkestr logs my-api
50
+
51
+ # Check health
52
+ orkestr health my-api
53
+ ```
54
+
55
+ ## Commands
56
+
57
+ | Command | Description |
58
+ |---|---|
59
+ | `orkestr login` | Authenticate with an API token |
60
+ | `orkestr logout` | Clear stored credentials |
61
+ | `orkestr whoami` | Show current user info |
62
+ | `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
63
+ | `orkestr envs list\|create\|update\|delete` | Manage environments |
64
+ | `orkestr deploy <project>` | Trigger a deployment |
65
+ | `orkestr deployments list\|show\|logs\|rollback` | Deployment history |
66
+ | `orkestr logs <project>` | Runtime container logs |
67
+ | `orkestr stats <project>` | CPU/memory/network stats |
68
+ | `orkestr health <project>` | Container health info |
69
+
70
+ All commands support `--json` for raw JSON output.
71
+
72
+ ## Documentation
73
+
74
+ Full docs at [orkestr.eu/docs](https://orkestr.eu/docs)
@@ -0,0 +1,10 @@
1
+ orkestr_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ orkestr_cli/api.py,sha256=sDcDRc1kWDcI6sNpZJUGxhBkvhti0ByNULzD-OYAkb4,6261
3
+ orkestr_cli/config.py,sha256=iC5sXHklxQ0QkaKSpZIk_AMQyTizRqNIznbsqmcL0LQ,1943
4
+ orkestr_cli/main.py,sha256=ugHYdD50LRfQMdU183sLOw7ImXa0uyRpOt6d6a1juqA,16476
5
+ orkestr_cli/output.py,sha256=KdP31LYFzyYDbOY25Ctq6Qg7jga5WtgPAHEOKDV_IIU,1466
6
+ orkestr_cli-0.1.0.dist-info/METADATA,sha256=9kFsFH6LWy0lFg2EUCMkm1dJe91m2Xzm1eDGDrEGyqk,2073
7
+ orkestr_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ orkestr_cli-0.1.0.dist-info/entry_points.txt,sha256=5SHqNcPp8OmqT6fzC2A8N5H8jp8PNrRaTE4UtvMyD1g,49
9
+ orkestr_cli-0.1.0.dist-info/top_level.txt,sha256=xTY9D1RhX1fP7vxK4RlK8owWvjijvZZNXNBoSfyH7LE,12
10
+ orkestr_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orkestr = orkestr_cli.main:cli
@@ -0,0 +1 @@
1
+ orkestr_cli