orkestr-cli 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orkestr-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI for the Orkestr deployment platform
5
5
  Author-email: Orkestr <hello@orkestr.eu>
6
6
  License-Expression: MIT
@@ -39,28 +39,35 @@ pip install orkestr-cli
39
39
  # Authenticate with your API token
40
40
  orkestr login
41
41
 
42
- # List your projects
43
- orkestr projects list
42
+ # From inside your app's git repo: detect name/repo/branch and create the project
43
+ orkestr init
44
44
 
45
- # Deploy
45
+ # Build, deploy, and wait for the live URL
46
+ orkestr deploy .
47
+
48
+ # ...or deploy a named project
46
49
  orkestr deploy my-api
47
50
 
48
- # View logs
51
+ # View logs / check health
49
52
  orkestr logs my-api
50
-
51
- # Check health
52
53
  orkestr health my-api
53
54
  ```
54
55
 
56
+ `orkestr init` writes a gitignored `.orkestr/config.json` linking the
57
+ directory to the project, so `orkestr deploy .` knows what to ship. It needs
58
+ an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
59
+ Codeberg) connected to that account.
60
+
55
61
  ## Commands
56
62
 
57
63
  | Command | Description |
58
64
  |---|---|
59
65
  | `orkestr login` / `logout` / `whoami` | Auth |
66
+ | `orkestr init` | Link the current directory to a new project (interactive) |
60
67
  | `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
61
68
  | `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
62
69
  | `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
63
- | `orkestr deploy <project>` | Trigger a deployment |
70
+ | `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
64
71
  | `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
65
72
  | `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
66
73
  | `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
@@ -14,28 +14,35 @@ pip install orkestr-cli
14
14
  # Authenticate with your API token
15
15
  orkestr login
16
16
 
17
- # List your projects
18
- orkestr projects list
17
+ # From inside your app's git repo: detect name/repo/branch and create the project
18
+ orkestr init
19
19
 
20
- # Deploy
20
+ # Build, deploy, and wait for the live URL
21
+ orkestr deploy .
22
+
23
+ # ...or deploy a named project
21
24
  orkestr deploy my-api
22
25
 
23
- # View logs
26
+ # View logs / check health
24
27
  orkestr logs my-api
25
-
26
- # Check health
27
28
  orkestr health my-api
28
29
  ```
29
30
 
31
+ `orkestr init` writes a gitignored `.orkestr/config.json` linking the
32
+ directory to the project, so `orkestr deploy .` knows what to ship. It needs
33
+ an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
34
+ Codeberg) connected to that account.
35
+
30
36
  ## Commands
31
37
 
32
38
  | Command | Description |
33
39
  |---|---|
34
40
  | `orkestr login` / `logout` / `whoami` | Auth |
41
+ | `orkestr init` | Link the current directory to a new project (interactive) |
35
42
  | `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
36
43
  | `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
37
44
  | `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
38
- | `orkestr deploy <project>` | Trigger a deployment |
45
+ | `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
39
46
  | `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
40
47
  | `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
41
48
  | `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
@@ -97,7 +97,7 @@ def list_projects(skip: int = 0, limit: int = 20) -> dict:
97
97
 
98
98
  def create_project(name: str, repo_url: str, branch: str = "main",
99
99
  display_name: str | None = None, description: str | None = None,
100
- env_vars: dict | None = None) -> dict:
100
+ env_vars: dict | None = None, build_context: str | None = None) -> dict:
101
101
  payload = {"name": name, "repo_url": repo_url, "repo_branch": branch}
102
102
  if display_name:
103
103
  payload["display_name"] = display_name
@@ -105,9 +105,21 @@ def create_project(name: str, repo_url: str, branch: str = "main",
105
105
  payload["description"] = description
106
106
  if env_vars:
107
107
  payload["env_vars"] = env_vars
108
+ if build_context:
109
+ payload["build_context"] = build_context
108
110
  return _post("/api/v1/projects", json=payload)
109
111
 
110
112
 
113
+ def detect_services(repo_url: str, branch: str = "main") -> dict:
114
+ """Detect deployable services in a repo (monorepo support).
115
+
116
+ Note: detect-services is served under /api/projects (not the /api/v1
117
+ prefix the rest of these calls use) — confirmed against the live API.
118
+ """
119
+ return _post("/api/projects/detect-services",
120
+ params={"repo_url": repo_url, "branch": branch})
121
+
122
+
111
123
  def get_project(project_id: str) -> dict:
112
124
  return _get(f"/api/v1/projects/{project_id}")
113
125
 
@@ -0,0 +1,108 @@
1
+ """Read repo metadata straight from a local .git directory.
2
+
3
+ No git binary required — init only needs the origin URL and current branch,
4
+ both of which are plain text in .git/config and .git/HEAD. Parsing them
5
+ ourselves keeps init working in containers and minimal images that ship
6
+ without the git CLI.
7
+ """
8
+
9
+ import re
10
+ from pathlib import Path
11
+
12
+
13
+ def find_git_root(start: Path | None = None) -> Path | None:
14
+ """Walk up from start (default cwd) to the directory containing .git."""
15
+ path = (start or Path.cwd()).resolve()
16
+ for candidate in [path, *path.parents]:
17
+ if (candidate / ".git").exists():
18
+ return candidate
19
+ return None
20
+
21
+
22
+ def normalize_remote_url(url: str) -> str:
23
+ """Normalize any git remote URL to an https:// clone URL.
24
+
25
+ Handles the three forms a remote shows up as:
26
+ git@github.com:user/repo.git (scp-like ssh)
27
+ ssh://git@github.com/user/repo.git (ssh url)
28
+ https://github.com/user/repo.git (already https)
29
+ The trailing .git and any embedded credentials are stripped.
30
+ """
31
+ url = url.strip()
32
+
33
+ # scp-like: [user@]host:path
34
+ m = re.match(r"^(?:ssh://)?(?:[^@/]+@)?([^:/]+):(.+?)(?:\.git)?/?$", url)
35
+ if m and "://" not in url:
36
+ return f"https://{m.group(1)}/{m.group(2)}"
37
+
38
+ # ssh://[user@]host[:port]/path
39
+ m = re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url)
40
+ if m:
41
+ return f"https://{m.group(1)}/{m.group(2)}"
42
+
43
+ # http(s)://[user[:pass]@]host/path — strip creds + trailing .git
44
+ m = re.match(r"^(https?://)(?:[^@/]+@)?(.+?)(?:\.git)?/?$", url)
45
+ if m:
46
+ return f"{m.group(1)}{m.group(2)}"
47
+
48
+ return url
49
+
50
+
51
+ def read_origin_url(git_root: Path) -> str | None:
52
+ """Return the normalized origin remote URL, or the first remote found.
53
+
54
+ Parses .git/config by hand rather than via configparser: git indents
55
+ entries with tabs, which configparser misreads as value continuations.
56
+ """
57
+ cfg = git_root / ".git" / "config"
58
+ if not cfg.exists():
59
+ return None
60
+
61
+ remotes: dict[str, str] = {}
62
+ current_remote: str | None = None
63
+ for raw in cfg.read_text(errors="replace").splitlines():
64
+ line = raw.strip()
65
+ if line.startswith("[") and line.endswith("]"):
66
+ section = line[1:-1].strip()
67
+ if section.startswith("remote "):
68
+ # [remote "origin"] -> origin
69
+ current_remote = section.split('"')[1] if '"' in section else section[7:].strip()
70
+ else:
71
+ current_remote = None
72
+ continue
73
+ if current_remote and "=" in line:
74
+ key, _, value = line.partition("=")
75
+ if key.strip().lower() == "url" and value.strip():
76
+ remotes[current_remote] = normalize_remote_url(value.strip())
77
+
78
+ return remotes.get("origin") or next(iter(remotes.values()), None)
79
+
80
+
81
+ def read_current_branch(git_root: Path) -> str | None:
82
+ """Return the checked-out branch name, or None if detached."""
83
+ head = git_root / ".git" / "HEAD"
84
+ if not head.exists():
85
+ return None
86
+ text = head.read_text(errors="replace").strip()
87
+ if text.startswith("ref:"):
88
+ # ref: refs/heads/main -> main
89
+ return text.split("refs/heads/", 1)[-1] if "refs/heads/" in text else None
90
+ return None
91
+
92
+
93
+ def slugify(name: str) -> str:
94
+ """Derive a valid orkestr project slug from a directory name.
95
+
96
+ Slugs must match ^[a-z0-9][a-z0-9-]*$ and be 2-100 chars. Lowercases,
97
+ replaces runs of non-alphanumerics with a single hyphen, trims hyphens,
98
+ and pads to the 2-char minimum.
99
+ """
100
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
101
+ slug = re.sub(r"-{2,}", "-", slug)
102
+ if not slug:
103
+ slug = "app"
104
+ if not slug[0].isalnum():
105
+ slug = "a" + slug
106
+ if len(slug) < 2:
107
+ slug = (slug + "-app")[:100]
108
+ return slug[:100]
@@ -0,0 +1,62 @@
1
+ """Project-local config: ./.orkestr/config.json.
2
+
3
+ `orkestr init` writes this file to tie the current directory to an orkestr
4
+ project, so `orkestr deploy .` knows what "." means. It is gitignored
5
+ (local-only, per-developer) — distinct from the global ~/.orkestr/config.json
6
+ that holds credentials and the API URL.
7
+ """
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ LOCAL_DIR_NAME = ".orkestr"
13
+ LOCAL_FILE_NAME = "config.json"
14
+
15
+
16
+ def local_dir(root: Path | None = None) -> Path:
17
+ return (root or Path.cwd()) / LOCAL_DIR_NAME
18
+
19
+
20
+ def find_local_config(start: Path | None = None) -> Path | None:
21
+ """Walk up from start (default cwd) to find a .orkestr/config.json."""
22
+ path = (start or Path.cwd()).resolve()
23
+ for candidate in [path, *path.parents]:
24
+ cfg = candidate / LOCAL_DIR_NAME / LOCAL_FILE_NAME
25
+ if cfg.exists():
26
+ return cfg
27
+ return None
28
+
29
+
30
+ def load_local(start: Path | None = None) -> dict | None:
31
+ """Load the nearest project-local config, or None if there isn't one."""
32
+ path = find_local_config(start)
33
+ if not path:
34
+ return None
35
+ try:
36
+ return json.loads(path.read_text())
37
+ except (json.JSONDecodeError, OSError):
38
+ return None
39
+
40
+
41
+ def write_local(data: dict, root: Path | None = None) -> Path:
42
+ """Write .orkestr/config.json under root (default cwd) and gitignore it."""
43
+ root = root or Path.cwd()
44
+ directory = local_dir(root)
45
+ directory.mkdir(parents=True, exist_ok=True)
46
+ path = directory / LOCAL_FILE_NAME
47
+ path.write_text(json.dumps(data, indent=2) + "\n")
48
+ _ensure_gitignored(root)
49
+ return path
50
+
51
+
52
+ def _ensure_gitignored(root: Path) -> None:
53
+ """Add `.orkestr/` to the repo's .gitignore if not already ignored."""
54
+ gitignore = root / ".gitignore"
55
+ entry = f"{LOCAL_DIR_NAME}/"
56
+ lines = gitignore.read_text().splitlines() if gitignore.exists() else []
57
+ if any(line.strip() in (entry, LOCAL_DIR_NAME) for line in lines):
58
+ return
59
+ with gitignore.open("a") as f:
60
+ if lines and lines[-1].strip():
61
+ f.write("\n")
62
+ f.write(f"# orkestr CLI local project config\n{entry}\n")
@@ -1,8 +1,11 @@
1
1
  """Orkestr CLI — manage your projects, deployments, and infrastructure."""
2
2
 
3
+ import time
4
+ from pathlib import Path
5
+
3
6
  import click
4
7
 
5
- from orkestr_cli import api, config
8
+ from orkestr_cli import api, config, gitinfo, localconfig
6
9
  from orkestr_cli.output import (
7
10
  console,
8
11
  format_dt,
@@ -22,7 +25,7 @@ from orkestr_cli.output import (
22
25
  @click.option("--api-url", envvar="ORKESTR_API_URL", help="Override API base URL.")
23
26
  @click.option("--token", envvar="ORKESTR_API_TOKEN", help="API token for auth.")
24
27
  @click.option("--json", "use_json", is_flag=True, help="Output raw JSON.")
25
- @click.version_option("0.2.0", prog_name="orkestr")
28
+ @click.version_option("0.3.0", prog_name="orkestr")
26
29
  @click.pass_context
27
30
  def cli(ctx, api_url, token, use_json):
28
31
  """Orkestr CLI — deploy and manage your applications."""
@@ -464,22 +467,278 @@ def addons_delete_backup(project, addon_id, backup_id):
464
467
  # ---------------------------------------------------------------------------
465
468
 
466
469
  @cli.command()
467
- @click.argument("project")
470
+ @click.argument("project", required=False)
468
471
  @click.option("--env", "environment_id", help="Environment ID (defaults to production).")
472
+ @click.option("--detach", "-d", is_flag=True,
473
+ help="Trigger and return immediately, without waiting (for CI).")
474
+ @click.option("--timeout", default=600, show_default=True,
475
+ help="Max seconds to wait for the deploy to finish.")
469
476
  @click.pass_context
470
- def deploy(ctx, project, environment_id):
471
- """Trigger a deployment."""
472
- project_id = api.resolve_project_id(project)
477
+ def deploy(ctx, project, environment_id, detach, timeout):
478
+ """Build and deploy a project, waiting until it's live.
479
+
480
+ PROJECT is a project name. Pass "." (or omit it) to deploy the project
481
+ linked to the current directory by 'orkestr init'. Use --detach to fire
482
+ the deploy and return immediately without streaming the pipeline.
483
+ """
484
+ project_id = _resolve_deploy_target(project)
473
485
  data = api.trigger_deployment(project_id, environment_id)
486
+ deployment_id = data["id"]
487
+
488
+ if detach:
489
+ if ctx.obj["json"]:
490
+ print_json(data)
491
+ return
492
+ click.secho("Deployment triggered.", fg="green")
493
+ print_kv([
494
+ ("Deployment ID:", deployment_id),
495
+ ("Status:", data.get("status", "-")),
496
+ ])
497
+ click.echo(f"\nTrack progress: orkestr deployments show {deployment_id}")
498
+ return
499
+
500
+ final = _wait_for_deployment(deployment_id, timeout, json_mode=ctx.obj["json"])
501
+
474
502
  if ctx.obj["json"]:
475
- print_json(data)
503
+ print_json(final)
504
+ if final.get("status") != "live":
505
+ raise SystemExit(1)
476
506
  return
477
- click.secho("Deployment triggered.", fg="green")
507
+
508
+ click.echo()
509
+ if final.get("status") == "live":
510
+ domain = _project_domain(project_id)
511
+ if domain:
512
+ click.secho(f" Live at https://{domain}", fg="green", bold=True)
513
+ else:
514
+ click.secho(" Deployment live.", fg="green", bold=True)
515
+ else:
516
+ click.secho(" Deployment failed.", fg="red", bold=True)
517
+ click.echo(f" See logs: orkestr deployments logs {deployment_id}")
518
+ raise SystemExit(1)
519
+
520
+
521
+ def _resolve_deploy_target(project: str | None) -> str:
522
+ """Resolve a deploy target to a project id, honoring ./.orkestr/config.json.
523
+
524
+ `.` (or no argument) means "the project linked to this directory". This
525
+ short-circuits before resolve_project_id, which would otherwise treat "."
526
+ as a name and fail to find it.
527
+ """
528
+ if project in (None, "", "."):
529
+ cfg = localconfig.load_local()
530
+ if not cfg or not cfg.get("project"):
531
+ click.secho(
532
+ "No orkestr project linked to this directory. "
533
+ "Run 'orkestr init' first, or pass a project name.",
534
+ fg="red", err=True,
535
+ )
536
+ raise SystemExit(1)
537
+ # The stored project_id is canonical — use it directly rather than
538
+ # round-tripping through the name/UUID heuristic in resolve_project_id.
539
+ if cfg.get("project_id"):
540
+ return cfg["project_id"]
541
+ return api.resolve_project_id(cfg["project"])
542
+ return api.resolve_project_id(project)
543
+
544
+
545
+ def _wait_for_deployment(deployment_id: str, timeout: int, json_mode: bool) -> dict:
546
+ """Poll a deployment until it is live or failed, streaming step transitions."""
547
+ seen: dict[str, str] = {}
548
+ waited = 0
549
+ interval = 2
550
+ while True:
551
+ data = api.get_deployment(deployment_id)
552
+ if not json_mode:
553
+ _render_step_transitions(data.get("steps", []), seen)
554
+ if data.get("status") in ("live", "failed"):
555
+ return data
556
+ if waited >= timeout:
557
+ if not json_mode:
558
+ click.secho(f"\n Timed out after {timeout}s.", fg="yellow")
559
+ return data
560
+ time.sleep(interval)
561
+ waited += interval
562
+
563
+
564
+ def _render_step_transitions(steps: list, seen: dict) -> None:
565
+ """Print a line each time a pipeline step changes state."""
566
+ for s in steps:
567
+ name = s.get("step_name", "")
568
+ status = s.get("status", "")
569
+ if not name or seen.get(name) == status:
570
+ continue
571
+ seen[name] = status
572
+ if status == "running":
573
+ click.secho(f" > {name} ...", fg="cyan")
574
+ elif status == "completed":
575
+ click.secho(f" OK {name}", fg="green")
576
+ elif status == "failed":
577
+ click.secho(f" FAIL {name}", fg="red")
578
+
579
+
580
+ def _project_domain(project_id: str) -> str | None:
581
+ """Best-effort fetch of a project's live domain (not on the deployment payload)."""
582
+ try:
583
+ return api.get_project(project_id).get("domain")
584
+ except SystemExit:
585
+ return None
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # Init (interactive project setup)
590
+ # ---------------------------------------------------------------------------
591
+
592
+ @cli.command()
593
+ @click.option("--dir", "directory", default=".", type=click.Path(file_okay=False),
594
+ help="Project directory (default: current).")
595
+ @click.option("--name", help="Project name (slug). Default: derived from the directory.")
596
+ @click.option("--repo", help="Git repository URL. Default: the origin remote in .git.")
597
+ @click.option("--branch", help="Branch to deploy. Default: the current branch.")
598
+ @click.option("--yes", "-y", "assume_yes", is_flag=True,
599
+ help="Accept all detected defaults without prompting.")
600
+ @click.pass_context
601
+ def init(ctx, directory, name, repo, branch, assume_yes):
602
+ """Link this directory to a new orkestr project, interactively.
603
+
604
+ Detects the project name, git repo, and branch from the directory, lets
605
+ you confirm each with Enter, picks the service for monorepos, creates the
606
+ project, and writes .orkestr/config.json so you can then 'orkestr deploy .'.
607
+ """
608
+ if not config.is_logged_in():
609
+ click.secho("Not logged in. Run: orkestr login", fg="red", err=True)
610
+ raise SystemExit(1)
611
+ user = api.me()
612
+
613
+ root = Path(directory).resolve()
614
+ if not root.is_dir():
615
+ click.secho(f"Not a directory: {root}", fg="red", err=True)
616
+ raise SystemExit(1)
617
+
618
+ existing = localconfig.load_local(root)
619
+ if existing and existing.get("project"):
620
+ click.secho(f"This directory is already linked to '{existing['project']}'.",
621
+ fg="yellow")
622
+ if not assume_yes and not click.confirm("Re-initialize and overwrite?", default=False):
623
+ raise SystemExit(0)
624
+
625
+ git_root = gitinfo.find_git_root(root)
626
+ default_name = gitinfo.slugify(name or root.name)
627
+ default_repo = repo or (gitinfo.read_origin_url(git_root) if git_root else None)
628
+ default_branch = (
629
+ branch or (gitinfo.read_current_branch(git_root) if git_root else None) or "main"
630
+ )
631
+
632
+ click.secho(f"Initializing orkestr project in {root}", bold=True)
633
+ click.echo(f"Logged in as {user.get('username', 'unknown')}.\n")
634
+
635
+ project_name = _prompt_project_name(name or default_name, assume_yes)
636
+
637
+ project_repo = repo or default_repo
638
+ if not assume_yes:
639
+ project_repo = click.prompt(
640
+ "Git repository", default=default_repo or "",
641
+ show_default=bool(default_repo),
642
+ ).strip()
643
+ if not project_repo:
644
+ click.secho("A git repository URL is required (no origin remote found).",
645
+ fg="red", err=True)
646
+ raise SystemExit(1)
647
+
648
+ project_branch = branch or default_branch
649
+ if not assume_yes:
650
+ project_branch = click.prompt("Branch", default=default_branch).strip()
651
+
652
+ build_context = _pick_service(project_repo, project_branch, assume_yes)
653
+
654
+ dns = f"{project_name}.orkestr.run"
655
+ click.echo()
478
656
  print_kv([
479
- ("Deployment ID:", data["id"]),
480
- ("Status:", data.get("status", "-")),
657
+ ("Name:", project_name),
658
+ ("Repo:", project_repo),
659
+ ("Branch:", project_branch),
660
+ ("Service:", build_context or "(repo root)"),
661
+ ("DNS:", dns),
481
662
  ])
482
- click.echo(f"\nTrack progress: orkestr deployments show {data['id']}")
663
+ if not assume_yes and not click.confirm("\nCreate this project?", default=True):
664
+ click.secho("Aborted.", fg="yellow")
665
+ raise SystemExit(0)
666
+
667
+ # display_name must be >= 2 chars server-side; a 1-char dir name would 422.
668
+ display_name = root.name if len(root.name) >= 2 else project_name
669
+ data = api.create_project(
670
+ name=project_name,
671
+ repo_url=project_repo,
672
+ branch=project_branch,
673
+ display_name=display_name,
674
+ build_context=build_context,
675
+ )
676
+ localconfig.write_local({
677
+ "project": data["name"],
678
+ "project_id": data["id"],
679
+ "branch": project_branch,
680
+ "build_context": build_context or ".",
681
+ }, root=root)
682
+
683
+ click.echo()
684
+ click.secho(f"Project '{data['name']}' created and linked.", fg="green")
685
+ print_kv([
686
+ ("Domain:", data.get("domain") or dns),
687
+ ("Config:", ".orkestr/config.json"),
688
+ ])
689
+ click.echo("\nNext: orkestr deploy .")
690
+
691
+
692
+ def _prompt_project_name(default_name: str, assume_yes: bool) -> str:
693
+ """Prompt for a project name and ensure the slug is valid and available."""
694
+ name = gitinfo.slugify(default_name)
695
+ while True:
696
+ if not assume_yes:
697
+ name = gitinfo.slugify(click.prompt("Project name", default=name))
698
+ check = api.project_check_name(name)
699
+ if check.get("available"):
700
+ return name
701
+ reason = "reserved" if check.get("reason") == "reserved" else "already taken"
702
+ click.secho(f" '{name}' is {reason}.", fg="yellow")
703
+ if assume_yes:
704
+ for i in range(2, 100):
705
+ candidate = gitinfo.slugify(f"{default_name}-{i}")
706
+ if api.project_check_name(candidate).get("available"):
707
+ click.secho(f" Using '{candidate}'.", fg="cyan")
708
+ return candidate
709
+ click.secho("Could not find an available name.", fg="red", err=True)
710
+ raise SystemExit(1)
711
+ name = gitinfo.slugify(f"{name}-2")
712
+
713
+
714
+ def _pick_service(repo_url: str, branch: str, assume_yes: bool) -> str | None:
715
+ """Detect deployable services; return the chosen build_context (None = root)."""
716
+ click.echo(" Scanning the repo for services ...")
717
+ try:
718
+ resp = api.detect_services(repo_url, branch)
719
+ except SystemExit:
720
+ click.secho(" Could not scan the repo; defaulting to the repo root.",
721
+ fg="yellow")
722
+ return None
723
+ services = resp.get("services", []) if isinstance(resp, dict) else []
724
+
725
+ if len(services) <= 1:
726
+ only = services[0] if services else None
727
+ if only and only.get("path") not in (".", "", None):
728
+ return only.get("path")
729
+ return None
730
+
731
+ click.echo("\n Multiple services detected:")
732
+ for i, s in enumerate(services, 1):
733
+ click.echo(f" {i}. {s.get('path')} ({s.get('framework') or 'unknown'})")
734
+ if assume_yes:
735
+ chosen = services[0]
736
+ click.secho(f" Using '{chosen.get('path')}' (first match).", fg="cyan")
737
+ path = chosen.get("path")
738
+ return None if path in (".", "") else path
739
+ idx = click.prompt(" Pick a service", type=click.IntRange(1, len(services)), default=1)
740
+ path = services[idx - 1].get("path")
741
+ return None if path in (".", "") else path
483
742
 
484
743
 
485
744
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orkestr-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI for the Orkestr deployment platform
5
5
  Author-email: Orkestr <hello@orkestr.eu>
6
6
  License-Expression: MIT
@@ -39,28 +39,35 @@ pip install orkestr-cli
39
39
  # Authenticate with your API token
40
40
  orkestr login
41
41
 
42
- # List your projects
43
- orkestr projects list
42
+ # From inside your app's git repo: detect name/repo/branch and create the project
43
+ orkestr init
44
44
 
45
- # Deploy
45
+ # Build, deploy, and wait for the live URL
46
+ orkestr deploy .
47
+
48
+ # ...or deploy a named project
46
49
  orkestr deploy my-api
47
50
 
48
- # View logs
51
+ # View logs / check health
49
52
  orkestr logs my-api
50
-
51
- # Check health
52
53
  orkestr health my-api
53
54
  ```
54
55
 
56
+ `orkestr init` writes a gitignored `.orkestr/config.json` linking the
57
+ directory to the project, so `orkestr deploy .` knows what to ship. It needs
58
+ an orkestr account and the repo's git provider (GitHub, GitLab, Bitbucket, or
59
+ Codeberg) connected to that account.
60
+
55
61
  ## Commands
56
62
 
57
63
  | Command | Description |
58
64
  |---|---|
59
65
  | `orkestr login` / `logout` / `whoami` | Auth |
66
+ | `orkestr init` | Link the current directory to a new project (interactive) |
60
67
  | `orkestr projects list\|create\|show\|update\|delete\|analyze` | Manage projects |
61
68
  | `orkestr projects env-vars\|webhook\|webhook-toggle <project>` | Project env vars + git webhook |
62
69
  | `orkestr envs list\|create\|update\|delete\|env-vars` | Manage environments |
63
- | `orkestr deploy <project>` | Trigger a deployment |
70
+ | `orkestr deploy [project]` | Build + deploy, stream the pipeline, print the live URL (`.` = linked dir; `--detach` to not wait) |
64
71
  | `orkestr deployments list\|show\|logs\|build-log\|rollback` | Deployment history |
65
72
  | `orkestr functions list\|show\|create\|update\|deploy\|delete` | Serverless functions |
66
73
  | `orkestr functions logs\|invoke\|env-vars\|metrics\|rotate-key` | Function ops |
@@ -3,6 +3,8 @@ pyproject.toml
3
3
  orkestr_cli/__init__.py
4
4
  orkestr_cli/api.py
5
5
  orkestr_cli/config.py
6
+ orkestr_cli/gitinfo.py
7
+ orkestr_cli/localconfig.py
6
8
  orkestr_cli/main.py
7
9
  orkestr_cli/output.py
8
10
  orkestr_cli.egg-info/PKG-INFO
@@ -10,4 +12,5 @@ orkestr_cli.egg-info/SOURCES.txt
10
12
  orkestr_cli.egg-info/dependency_links.txt
11
13
  orkestr_cli.egg-info/entry_points.txt
12
14
  orkestr_cli.egg-info/requires.txt
13
- orkestr_cli.egg-info/top_level.txt
15
+ orkestr_cli.egg-info/top_level.txt
16
+ tests/test_gitinfo.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "orkestr-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "CLI for the Orkestr deployment platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,3 +40,4 @@ orkestr = "orkestr_cli.main:cli"
40
40
 
41
41
  [tool.setuptools.packages.find]
42
42
  where = ["."]
43
+ include = ["orkestr_cli*"]
@@ -0,0 +1,110 @@
1
+ """Tests for the pure git-metadata + slug helpers in gitinfo."""
2
+
3
+ from orkestr_cli import gitinfo
4
+
5
+
6
+ class TestNormalizeRemoteUrl:
7
+ def test_scp_like_ssh(self):
8
+ assert (gitinfo.normalize_remote_url("git@github.com:user/repo.git")
9
+ == "https://github.com/user/repo")
10
+
11
+ def test_scp_like_no_dot_git(self):
12
+ assert (gitinfo.normalize_remote_url("git@gitlab.com:group/sub/repo")
13
+ == "https://gitlab.com/group/sub/repo")
14
+
15
+ def test_ssh_url(self):
16
+ assert (gitinfo.normalize_remote_url("ssh://git@github.com/user/repo.git")
17
+ == "https://github.com/user/repo")
18
+
19
+ def test_ssh_url_with_port(self):
20
+ assert (gitinfo.normalize_remote_url("ssh://git@example.com:2222/user/repo.git")
21
+ == "https://example.com/user/repo")
22
+
23
+ def test_https_strips_dot_git(self):
24
+ assert (gitinfo.normalize_remote_url("https://github.com/user/repo.git")
25
+ == "https://github.com/user/repo")
26
+
27
+ def test_https_already_clean(self):
28
+ assert (gitinfo.normalize_remote_url("https://github.com/user/repo")
29
+ == "https://github.com/user/repo")
30
+
31
+ def test_https_strips_embedded_credentials(self):
32
+ assert (gitinfo.normalize_remote_url("https://token@github.com/user/repo.git")
33
+ == "https://github.com/user/repo")
34
+
35
+ def test_trailing_slash(self):
36
+ assert (gitinfo.normalize_remote_url("https://github.com/user/repo/")
37
+ == "https://github.com/user/repo")
38
+
39
+
40
+ class TestSlugify:
41
+ def test_lowercases(self):
42
+ assert gitinfo.slugify("MyApp") == "myapp"
43
+
44
+ def test_spaces_and_underscores(self):
45
+ assert gitinfo.slugify("my cool_app") == "my-cool-app"
46
+
47
+ def test_collapses_separators(self):
48
+ assert gitinfo.slugify("my___app") == "my-app"
49
+
50
+ def test_trims_leading_trailing(self):
51
+ assert gitinfo.slugify("--app--") == "app"
52
+
53
+ def test_leading_non_alnum_gets_prefix(self):
54
+ assert gitinfo.slugify("_private")[0].isalnum()
55
+
56
+ def test_empty_falls_back(self):
57
+ assert gitinfo.slugify("!!!") == "app"
58
+
59
+ def test_min_length(self):
60
+ assert len(gitinfo.slugify("x")) >= 2
61
+
62
+ def test_valid_pattern(self):
63
+ import re
64
+ for name in ["My App!", "123-go", "___", "a", "Über-Service"]:
65
+ slug = gitinfo.slugify(name)
66
+ assert re.match(r"^[a-z0-9][a-z0-9-]*$", slug), slug
67
+
68
+
69
+ class TestReadGitConfig:
70
+ def _write_repo(self, tmp_path, config_text, head_text="ref: refs/heads/main\n"):
71
+ git = tmp_path / ".git"
72
+ git.mkdir()
73
+ (git / "config").write_text(config_text)
74
+ (git / "HEAD").write_text(head_text)
75
+ return tmp_path
76
+
77
+ def test_reads_origin_with_tabs(self, tmp_path):
78
+ # git indents entries with tabs — the parser must handle that.
79
+ cfg = (
80
+ '[core]\n\trepositoryformatversion = 0\n'
81
+ '[remote "origin"]\n\turl = git@github.com:user/repo.git\n'
82
+ '\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
83
+ )
84
+ root = self._write_repo(tmp_path, cfg)
85
+ assert gitinfo.read_origin_url(root) == "https://github.com/user/repo"
86
+
87
+ def test_prefers_origin_over_other_remote(self, tmp_path):
88
+ cfg = (
89
+ '[remote "upstream"]\n\turl = https://github.com/up/stream.git\n'
90
+ '[remote "origin"]\n\turl = https://github.com/me/mine.git\n'
91
+ )
92
+ root = self._write_repo(tmp_path, cfg)
93
+ assert gitinfo.read_origin_url(root) == "https://github.com/me/mine"
94
+
95
+ def test_falls_back_to_first_remote(self, tmp_path):
96
+ cfg = '[remote "fork"]\n\turl = https://github.com/x/y.git\n'
97
+ root = self._write_repo(tmp_path, cfg)
98
+ assert gitinfo.read_origin_url(root) == "https://github.com/x/y"
99
+
100
+ def test_no_remote_returns_none(self, tmp_path):
101
+ root = self._write_repo(tmp_path, "[core]\n\tbare = false\n")
102
+ assert gitinfo.read_origin_url(root) is None
103
+
104
+ def test_current_branch(self, tmp_path):
105
+ root = self._write_repo(tmp_path, "[core]\n", head_text="ref: refs/heads/feature/x\n")
106
+ assert gitinfo.read_current_branch(root) == "feature/x"
107
+
108
+ def test_detached_head_returns_none(self, tmp_path):
109
+ root = self._write_repo(tmp_path, "[core]\n", head_text="abc123def456\n")
110
+ assert gitinfo.read_current_branch(root) is None
File without changes