flowstash-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.
Files changed (46) hide show
  1. flowstash_cli-0.1.0/PKG-INFO +19 -0
  2. flowstash_cli-0.1.0/flowstash/__init__.py +0 -0
  3. flowstash_cli-0.1.0/flowstash/commands/__init__.py +0 -0
  4. flowstash_cli-0.1.0/flowstash/commands/auth.py +112 -0
  5. flowstash_cli-0.1.0/flowstash/commands/build.py +127 -0
  6. flowstash_cli-0.1.0/flowstash/commands/deploy.py +143 -0
  7. flowstash_cli-0.1.0/flowstash/commands/project.py +555 -0
  8. flowstash_cli-0.1.0/flowstash/commands/run.py +65 -0
  9. flowstash_cli-0.1.0/flowstash/core/__init__.py +0 -0
  10. flowstash_cli-0.1.0/flowstash/core/api_client.py +62 -0
  11. flowstash_cli-0.1.0/flowstash/core/auth_server.py +45 -0
  12. flowstash_cli-0.1.0/flowstash/core/builder.py +40 -0
  13. flowstash_cli-0.1.0/flowstash/core/config.py +81 -0
  14. flowstash_cli-0.1.0/flowstash/core/docker_utils.py +33 -0
  15. flowstash_cli-0.1.0/flowstash/main.py +269 -0
  16. flowstash_cli-0.1.0/flowstash/templates/AGENTS.md +222 -0
  17. flowstash_cli-0.1.0/flowstash/templates/README.md +135 -0
  18. flowstash_cli-0.1.0/flowstash/templates/_.dockerignore +8 -0
  19. flowstash_cli-0.1.0/flowstash/templates/_.flowstash +4 -0
  20. flowstash_cli-0.1.0/flowstash/templates/_api_main.py +21 -0
  21. flowstash_cli-0.1.0/flowstash/templates/_config/[env]/(backend-asyncio)/backend.yaml +4 -0
  22. flowstash_cli-0.1.0/flowstash/templates/_config/[env]/(backend-dramatiq)/backend.yaml +8 -0
  23. flowstash_cli-0.1.0/flowstash/templates/_config/[env]/(backend-managed)/backend.yaml +6 -0
  24. flowstash_cli-0.1.0/flowstash/templates/_config/[env]/_backend.yaml +4 -0
  25. flowstash_cli-0.1.0/flowstash/templates/_config/shared/.env +1 -0
  26. flowstash_cli-0.1.0/flowstash/templates/_config/shared/backend.yaml +4 -0
  27. flowstash_cli-0.1.0/flowstash/templates/_config/shared/clients/demoClient.yaml +39 -0
  28. flowstash_cli-0.1.0/flowstash/templates/_config/shared/clients.yaml +3 -0
  29. flowstash_cli-0.1.0/flowstash/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +24 -0
  30. flowstash_cli-0.1.0/flowstash/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +34 -0
  31. flowstash_cli-0.1.0/flowstash/templates/_deployment/[env]/.env +3 -0
  32. flowstash_cli-0.1.0/flowstash/templates/_deployment/shared/.env +5 -0
  33. flowstash_cli-0.1.0/flowstash/templates/_deployment/shared/api.Dockerfile +18 -0
  34. flowstash_cli-0.1.0/flowstash/templates/_deployment/shared/worker.Dockerfile +18 -0
  35. flowstash_cli-0.1.0/flowstash/templates/_pyproject.toml +40 -0
  36. flowstash_cli-0.1.0/flowstash/templates/_src/_api/__init__.py +1 -0
  37. flowstash_cli-0.1.0/flowstash/templates/_src/_api/_routes/webhooks.py +25 -0
  38. flowstash_cli-0.1.0/flowstash/templates/_src/_shared/__init__.py +1 -0
  39. flowstash_cli-0.1.0/flowstash/templates/_src/_shared/clients/client.py +18 -0
  40. flowstash_cli-0.1.0/flowstash/templates/_src/_shared/models/models.py +1 -0
  41. flowstash_cli-0.1.0/flowstash/templates/_src/_shared/tasks/sharedTasks.py +10 -0
  42. flowstash_cli-0.1.0/flowstash/templates/_src/_worker/__init__.py +1 -0
  43. flowstash_cli-0.1.0/flowstash/templates/_src/_worker/tasks/tasks.py +15 -0
  44. flowstash_cli-0.1.0/flowstash/templates/_worker_main.py +25 -0
  45. flowstash_cli-0.1.0/flowstash/ui/__init__.py +0 -0
  46. flowstash_cli-0.1.0/pyproject.toml +31 -0
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.3
2
+ Name: flowstash-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the flowstash Managed Platform
5
+ Author: juraj.bezdek@gmail.com
6
+ Author-email: juraj.bezdek@gmail.com
7
+ Requires-Python: >=3.11
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Dist: flowstash-runtime (>=0.3.0,<0.4.0)
13
+ Requires-Dist: httpx (>=0.27.0)
14
+ Requires-Dist: keyring (>=25.0.0)
15
+ Requires-Dist: pydantic (>=2.0.0)
16
+ Requires-Dist: pyyaml (>=6.0.1)
17
+ Requires-Dist: questionary (>=2.0.1)
18
+ Requires-Dist: rich (>=13.7.0)
19
+ Requires-Dist: typer[all] (>=0.12.0)
File without changes
File without changes
@@ -0,0 +1,112 @@
1
+ from typing import Optional
2
+ import typer
3
+ import webbrowser
4
+ import uuid
5
+ import secrets
6
+ from rich.console import Console
7
+ import asyncio
8
+ import httpx
9
+ from ..core.auth_server import start_callback_server
10
+ from ..core.config import (
11
+ load_global_config,
12
+ set_access_token,
13
+ get_access_token,
14
+ delete_access_token,
15
+ load_project_config
16
+ )
17
+
18
+ app = typer.Typer()
19
+ console = Console()
20
+ import os
21
+ API_URL = os.getenv("flowstash_API_URL", "https://api.flowstash.dev")
22
+
23
+ @app.command()
24
+ def login(
25
+ username: Optional[str] = typer.Option(None, "--username", "-u", help="Username for manual login"),
26
+ password: Optional[str] = typer.Option(None, "--password", "-p", help="Password for manual login")
27
+ ):
28
+ """Log in to the flowstash Managed Platform."""
29
+ if username and password:
30
+ # Manual login
31
+ console.print(f"Logging in to {API_URL} as {username}...")
32
+
33
+ async def do_login():
34
+ async with httpx.AsyncClient() as client:
35
+ try:
36
+ resp = await client.post(
37
+ f"{API_URL}/v1/auth/login",
38
+ json={"email": username, "password": password}
39
+ )
40
+ resp.raise_for_status()
41
+ return resp.json()
42
+ except Exception as e:
43
+ console.print(f"[red]Login failed: {e}[/red]")
44
+ return None
45
+
46
+ result = asyncio.run(do_login())
47
+ if not result:
48
+ raise typer.Exit(code=1)
49
+
50
+ access_token = result.get("access_token")
51
+ tenant_id = result.get("tenant_id")
52
+
53
+ set_access_token(access_token)
54
+ console.print(f"[green]Successfully logged in as tenant: {tenant_id}[/green]")
55
+ return
56
+
57
+ # Browser login
58
+ state = secrets.token_urlsafe(16)
59
+ port = 8888
60
+ callback_url = f"http://localhost:{port}/callback"
61
+
62
+ login_url = f"{API_URL}/cli-login?redirect_uri={callback_url}&state={state}"
63
+
64
+ console.print(f"Opening your browser to authenticate...")
65
+ console.print(f"If the browser doesn't open, visit: [link={login_url}]{login_url}[/link]")
66
+
67
+ webbrowser.open(login_url)
68
+
69
+ console.print("Waiting for authentication...")
70
+ result = start_callback_server(port)
71
+
72
+ if not result:
73
+ console.print("[red]Authentication timed out or failed.[/red]")
74
+ raise typer.Exit(code=1)
75
+
76
+ if result.get("state") != state:
77
+ console.print("[red]Invalid state received. Auth session might be compromised.[/red]")
78
+ raise typer.Exit(code=1)
79
+
80
+ access_token = result.get("access_token")
81
+ tenant_id = result.get("tenant_id")
82
+
83
+ if not access_token:
84
+ console.print("[red]No access token received.[/red]")
85
+ raise typer.Exit(code=1)
86
+
87
+ set_access_token(access_token)
88
+
89
+ console.print(f"[green]Successfully logged in as tenant: {tenant_id}[/green]")
90
+
91
+ @app.command()
92
+ def logout():
93
+ """Log out from the flowstash Managed Platform."""
94
+ delete_access_token()
95
+ console.print("[yellow]Logged out successfully.[/yellow]")
96
+
97
+ @app.command()
98
+ def whoami():
99
+ """Show current login status."""
100
+ token = get_access_token()
101
+ global_config = load_global_config()
102
+ project_config = load_project_config()
103
+
104
+ if token:
105
+ console.print(f"API URL: {global_config.api_url}")
106
+ if project_config:
107
+ console.print(f"Logged in as tenant: [bold]{project_config.tenant_id}[/bold]")
108
+ console.print(f"Current Project: [bold]{project_config.project_id}[/bold]")
109
+ else:
110
+ console.print("Logged in, but no project context found in this directory.")
111
+ else:
112
+ console.print("Not logged in.")
@@ -0,0 +1,127 @@
1
+ import typer
2
+ import asyncio
3
+ import time
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.progress import Progress, SpinnerColumn, TextColumn
7
+ from ..core.api_client import APIClient
8
+ from ..core.config import load_project_config
9
+ from ..core.builder import bundle_source
10
+
11
+ import subprocess
12
+
13
+ app = typer.Typer()
14
+ console = Console()
15
+
16
+ from .project import find_project_root
17
+ from ..core.docker_utils import check_docker_binary, check_docker_daemon, get_docker_compose_cmd
18
+
19
+ async def run_managed_build(tag: str = "latest"):
20
+ project_config = load_project_config()
21
+ if not project_config or not project_config.project_id:
22
+ console.print("[red]Project not linked. Run 'flowstash init' to link to a managed project.[/red]")
23
+ raise typer.Exit(code=1)
24
+
25
+ api = APIClient()
26
+ # ... rest of existing managed build logic ...
27
+ # (I'll keep the existing implementation but wrap it)
28
+
29
+ @app.command()
30
+ def build(
31
+ env: str = typer.Argument(..., help="Environment to build"),
32
+ tag: str = typer.Option("latest", "--tag", "-t", help="Tag for the image")
33
+ ):
34
+ """Build project artifacts/images for the specified environment."""
35
+ project_config = load_project_config()
36
+
37
+ # Check if env is managed
38
+ is_managed = False
39
+ if project_config:
40
+ for em in project_config.environments:
41
+ if em.name == env:
42
+ is_managed = em.managed
43
+ break
44
+
45
+ if is_managed:
46
+ result = asyncio.run(run_build_flow(tag))
47
+ console.print(f"[green]Managed build completed successfully![/green]")
48
+ console.print(f"Artifact ID: [bold]{result['artifact_id']}[/bold]")
49
+ else:
50
+ # Local build
51
+ root = find_project_root()
52
+ if not root:
53
+ console.print("[red]Not in a flowstash project.[/red]")
54
+ raise typer.Exit(code=1)
55
+
56
+ if not check_docker_binary():
57
+ console.print("[red]Docker not found.[/red]")
58
+ raise typer.Exit(code=1)
59
+
60
+ compose_file = root / "deployment" / env / "docker-compose.yaml"
61
+ if not compose_file.exists():
62
+ console.print(f"[red]No docker-compose.yaml found for env '{env}' at {compose_file}[/red]")
63
+ raise typer.Exit(code=1)
64
+
65
+ cmd = get_docker_compose_cmd() + ["-f", str(compose_file), "build"]
66
+ console.print(f"Building images for [bold]{env}[/bold]...")
67
+ try:
68
+ subprocess.run(cmd, check=True, cwd=root)
69
+ console.print(f"[green]Local build for '{env}' complete![/green]")
70
+ except subprocess.CalledProcessError:
71
+ console.print("[red]Local build failed.[/red]")
72
+ raise typer.Exit(code=1)
73
+
74
+ async def run_build_flow(tag: str = "latest"):
75
+ # (Moved existing run_build_flow logic here for completeness in the file)
76
+ project_config = load_project_config()
77
+ project_id = project_config.project_id
78
+ api = APIClient()
79
+
80
+ try:
81
+ with Progress(
82
+ SpinnerColumn(),
83
+ TextColumn("[progress.description]{task.description}"),
84
+ ) as progress:
85
+ task = progress.add_task(description="Bundling source code...", total=None)
86
+ tar_path = bundle_source(Path.cwd())
87
+ progress.update(task, description="Source bundled.")
88
+
89
+ task = progress.add_task(description="Requesting upload path...", total=None)
90
+ upload_data = await api.get("/v1/builds/upload-path")
91
+ build_id = upload_data["build_id"]
92
+ upload_url = upload_data["upload_url"]
93
+ progress.update(task, description="Upload path received.")
94
+
95
+ task = progress.add_task(description="Uploading source...", total=None)
96
+ with open(tar_path, "rb") as f:
97
+ data = f.read()
98
+ await api.put_binary(upload_url, data, headers={"Content-Type": "application/gzip"})
99
+ progress.update(task, description="Source uploaded.")
100
+
101
+ task = progress.add_task(description="Triggering build...", total=None)
102
+ trigger_resp = await api.post(f"/v1/builds/{build_id}/trigger", json={"image_tag": tag})
103
+
104
+ # update build_id to the triggered true GCP build ID
105
+ build_id = trigger_resp["build_id"]
106
+ progress.update(task, description=f"Build triggered (ID: {build_id}).")
107
+
108
+ task = progress.add_task(description="Building...", total=None)
109
+ while True:
110
+ status_data = await api.get(f"/v1/builds/{build_id}/status")
111
+ status = status_data["status"]
112
+ if status == "SUCCESS":
113
+ progress.update(task, description="Build successful!")
114
+ return status_data
115
+ elif status in ["FAILURE", "INTERNAL_ERROR", "TIMEOUT", "CANCELLED"]:
116
+ progress.update(task, description=f"[red]Build failed: {status}[/red]")
117
+ console.print(f"[red]Build ended with status: {status}[/red]")
118
+ log_url = status_data.get('log_url')
119
+ if log_url:
120
+ console.print(f"Check logs here: [link={log_url}]{log_url}[/link]")
121
+ raise typer.Exit(code=1)
122
+ await asyncio.sleep(5)
123
+ except Exception as e:
124
+ if isinstance(e, typer.Exit):
125
+ raise
126
+ console.print(f"[red]Error during build: {e}[/red]")
127
+ raise typer.Exit(code=1)
@@ -0,0 +1,143 @@
1
+ from typing import Optional
2
+ import asyncio
3
+ import typer
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+ from rich.progress import Progress, SpinnerColumn, TextColumn
7
+ from ..core.api_client import APIClient
8
+ from ..core.config import load_project_config
9
+ from .build import run_build_flow
10
+
11
+ app = typer.Typer()
12
+ console = Console()
13
+
14
+ # Status labels shown to the user while polling
15
+ _STATUS_LABELS = {
16
+ "QUEUED": "Queued, waiting for deployment to start...",
17
+ "DEPLOYING": "Deploying services to Cloud Run...",
18
+ "HEALTH_CHECK": "Health-checking API and Worker...",
19
+ "SYNCING_SCHEDULES": "Fetching and syncing scheduled tasks...",
20
+ "DEPLOYED": "Deployed successfully ✓",
21
+ "FAILED": "Deployment failed.",
22
+ }
23
+
24
+ _TERMINAL_STATUSES = {"DEPLOYED", "FAILED"}
25
+
26
+
27
+ async def run_deploy_flow(env: str, artifact_id: Optional[str] = None):
28
+ project_config = load_project_config()
29
+ if not project_config:
30
+ console.print("[red]No .flowstash found. Please run 'flowstash init' first.[/red]")
31
+ raise typer.Exit(code=1)
32
+
33
+ project_id = project_config.project_id
34
+ if not project_id:
35
+ console.print("[red]project_id not found in .flowstash[/red]")
36
+ raise typer.Exit(code=1)
37
+
38
+ api = APIClient()
39
+
40
+ # 1. If artifact_id is not provided, run build first
41
+ if not artifact_id:
42
+ console.print("No artifact ID provided. Building first...")
43
+ build_result = await run_build_flow()
44
+ artifact_id = build_result["artifact_id"]
45
+ console.print(f"Build finished. Deploying artifact: [bold]{artifact_id}[/bold]")
46
+
47
+ with Progress(
48
+ SpinnerColumn(),
49
+ TextColumn("[progress.description]{task.description}"),
50
+ transient=True,
51
+ ) as progress:
52
+ # 2. Trigger deployment
53
+ task = progress.add_task(description="Triggering deployment...", total=None)
54
+ deploy_data = await api.post("/v1/deploy", json={
55
+ "project_id": project_id,
56
+ "artifact_id": artifact_id,
57
+ "env_vars": {"ENVIRONMENT": env}
58
+ })
59
+ deploy_id = deploy_data["deploy_id"]
60
+ progress.update(task, description=f"Deployment triggered (ID: {deploy_id}).")
61
+
62
+ # 3. Poll status until terminal
63
+ last_status = None
64
+ while True:
65
+ status_data = await api.get(f"/v1/deploy/{deploy_id}/status")
66
+ current_status = status_data["status"]
67
+
68
+ if current_status != last_status:
69
+ label = _STATUS_LABELS.get(current_status, f"Status: {current_status}")
70
+ progress.update(task, description=label)
71
+ last_status = current_status
72
+
73
+ if current_status == "DEPLOYED":
74
+ break
75
+ elif current_status == "FAILED":
76
+ error = status_data.get("error_message", "Unknown error")
77
+ console.print(f"[red]Deployment failed: {error}[/red]")
78
+ raise typer.Exit(code=1)
79
+
80
+ await asyncio.sleep(5)
81
+
82
+ return status_data
83
+
84
+
85
+ @app.command()
86
+ def deploy(
87
+ env: str = typer.Argument(..., help="Environment to deploy to"),
88
+ artifact: Optional[str] = typer.Option(None, "--artifact", "-a", help="Artifact ID to deploy"),
89
+ non_interactive: bool = typer.Option(False, "--non-interactive", help="Do not ask for confirmation")
90
+ ):
91
+ """Deploy an artifact to the managed platform for a specified environment."""
92
+ project_config = load_project_config()
93
+ if not project_config:
94
+ console.print("[red]No .flowstash found. Please run 'flowstash init' first.[/red]")
95
+ raise typer.Exit(code=1)
96
+
97
+ # Ask for confirmation unless non-interactive is provided
98
+ if not non_interactive:
99
+ from rich.prompt import Confirm
100
+ if not Confirm.ask(f"Are you sure you want to deploy to '{env}'?"):
101
+ console.print("Deployment cancelled.")
102
+ raise typer.Exit(code=0)
103
+
104
+ # Check if env is managed
105
+ is_managed = False
106
+ for em in project_config.environments:
107
+ if em.name == env:
108
+ is_managed = em.managed
109
+ break
110
+
111
+ if not is_managed:
112
+ console.print(f"[red]Environment '{env}' is not managed. Deployment is only supported for managed environments.[/red]")
113
+ console.print("[yellow]Update your .flowstash environments if this is incorrect.[/yellow]")
114
+ raise typer.Exit(code=1)
115
+
116
+ project_id = project_config.project_id
117
+ if not project_id:
118
+ if not non_interactive:
119
+ from rich.prompt import Confirm
120
+ if Confirm.ask("Project ID not found. Would you like to link to a managed project now?"):
121
+ from .project import _link_project
122
+ _link_project(project_config)
123
+ project_id = project_config.project_id
124
+
125
+ if not project_id:
126
+ console.print("[red]project_id not found in .flowstash. Use 'flowstash init' or link to a project.[/red]")
127
+ raise typer.Exit(code=1)
128
+
129
+ result = asyncio.run(run_deploy_flow(env, artifact))
130
+
131
+ api_url = result.get("api_url", "")
132
+ console.print(f"[green]✓ Deployment complete![/green]")
133
+ console.print(f" Deployment ID : [bold]{result['deploy_id']}[/bold]")
134
+ if api_url:
135
+ console.print(f" API URL : [bold]{api_url}[/bold]")
136
+
137
+ scheduled_tasks = result.get("scheduled_tasks")
138
+ if scheduled_tasks:
139
+ console.print(" Scheduled Tasks:")
140
+ for task in scheduled_tasks:
141
+ console.print(f" - [cyan]{task['task_name']}[/cyan] : [yellow]{task['cron']}[/yellow]")
142
+ elif scheduled_tasks is not None:
143
+ console.print(" Scheduled Tasks: [dim]None[/dim]")