flowstash-cli 0.7.3__tar.gz → 0.8.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.
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/PKG-INFO +2 -2
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/pyproject.toml +2 -2
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/apikey.py +24 -10
- flowstash_cli-0.8.0/src/flowstash/cli/commands/auth.py +286 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/build.py +19 -6
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/deploy.py +16 -6
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/project.py +6 -4
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/api_client.py +6 -3
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/auth_server.py +16 -0
- flowstash_cli-0.8.0/src/flowstash/cli/core/config.py +200 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/main.py +32 -8
- flowstash_cli-0.7.3/src/flowstash/cli/commands/auth.py +0 -151
- flowstash_cli-0.7.3/src/flowstash/cli/core/config.py +0 -81
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/client.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/run.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/webhook.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/builder.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/docker_utils.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/core/patcher.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/AGENTS.md +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/README.md +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_.dockerignore +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_.flowstash +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_api_main.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-asyncio)/backend.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-dramatiq)/backend.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-managed)/backend.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(observability-logfile)/observability.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(observability-managed)/observability.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/backend.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/clients/demoClient.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/clients.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/shared/api.Dockerfile +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/shared/worker.Dockerfile +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_pyproject.toml +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_api/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_api/_routes/webhooks.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/clients/client.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/models/models.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/tasks/sharedTasks.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/__init__.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_worker_main.py +0 -0
- {flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/ui/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: flowstash-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: CLI for the flowstash Managed Platform
|
|
5
5
|
Author: juraj.bezdek@gmail.com
|
|
6
6
|
Author-email: juraj.bezdek@gmail.com
|
|
@@ -9,7 +9,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.11
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.12
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
-
Requires-Dist: flowstash-runtime (>=0.
|
|
12
|
+
Requires-Dist: flowstash-runtime (>=0.8.0,<0.9.0)
|
|
13
13
|
Requires-Dist: httpx (>=0.27.0)
|
|
14
14
|
Requires-Dist: keyring (>=25.0.0)
|
|
15
15
|
Requires-Dist: libcst (>=1.1.0)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flowstash-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "CLI for the flowstash Managed Platform"
|
|
5
5
|
authors = [{name = "juraj.bezdek@gmail.com", email = "juraj.bezdek@gmail.com"}]
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"flowstash-runtime>=0.
|
|
8
|
+
"flowstash-runtime>=0.8.0,<0.9.0",
|
|
9
9
|
"typer[all]>=0.12.0",
|
|
10
10
|
"httpx>=0.27.0",
|
|
11
11
|
"pyyaml>=6.0.1",
|
|
@@ -24,7 +24,7 @@ from rich.prompt import Prompt
|
|
|
24
24
|
from rich.table import Table
|
|
25
25
|
|
|
26
26
|
from ..core.api_client import APIClient
|
|
27
|
-
from ..core.config import get_access_token
|
|
27
|
+
from ..core.config import get_access_token, resolve_credentials
|
|
28
28
|
|
|
29
29
|
app = typer.Typer(
|
|
30
30
|
name="api-keys",
|
|
@@ -103,9 +103,10 @@ def _create_key(
|
|
|
103
103
|
label: Optional[str],
|
|
104
104
|
scope: Optional[str],
|
|
105
105
|
env: Optional[str],
|
|
106
|
+
user: Optional[str] = None,
|
|
106
107
|
):
|
|
107
108
|
"""Shared implementation for 'new' and 'create'."""
|
|
108
|
-
token =
|
|
109
|
+
token = resolve_credentials(user=user)
|
|
109
110
|
if not token:
|
|
110
111
|
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
111
112
|
raise typer.Exit(code=1)
|
|
@@ -138,7 +139,7 @@ def _create_key(
|
|
|
138
139
|
)
|
|
139
140
|
|
|
140
141
|
async def _create():
|
|
141
|
-
api = APIClient()
|
|
142
|
+
api = APIClient(token=token)
|
|
142
143
|
return await api.post("/v1/api-keys", json={"label": label, "scopes": [scope]})
|
|
143
144
|
|
|
144
145
|
try:
|
|
@@ -175,9 +176,12 @@ def apikey_new(
|
|
|
175
176
|
env: Optional[str] = typer.Option(
|
|
176
177
|
None, "--env", "-e", help="Write key to this environment's .env file"
|
|
177
178
|
),
|
|
179
|
+
user: Optional[str] = typer.Option(
|
|
180
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
181
|
+
),
|
|
178
182
|
):
|
|
179
183
|
"""Create a new API key and optionally write it to an environment .env file."""
|
|
180
|
-
_create_key(label=label, scope=scope, env=env)
|
|
184
|
+
_create_key(label=label, scope=scope, env=env, user=user)
|
|
181
185
|
|
|
182
186
|
|
|
183
187
|
@app.command("create")
|
|
@@ -191,21 +195,28 @@ def apikey_create(
|
|
|
191
195
|
env: Optional[str] = typer.Option(
|
|
192
196
|
None, "--env", "-e", help="Write key to this environment's .env file"
|
|
193
197
|
),
|
|
198
|
+
user: Optional[str] = typer.Option(
|
|
199
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
200
|
+
),
|
|
194
201
|
):
|
|
195
202
|
"""Create a new API key (alias for 'new')."""
|
|
196
|
-
_create_key(label=label, scope=scope, env=env)
|
|
203
|
+
_create_key(label=label, scope=scope, env=env, user=user)
|
|
197
204
|
|
|
198
205
|
|
|
199
206
|
@app.command("list")
|
|
200
|
-
def apikey_list(
|
|
207
|
+
def apikey_list(
|
|
208
|
+
user: Optional[str] = typer.Option(
|
|
209
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
210
|
+
),
|
|
211
|
+
):
|
|
201
212
|
"""List all active API keys for the current tenant."""
|
|
202
|
-
token =
|
|
213
|
+
token = resolve_credentials(user=user)
|
|
203
214
|
if not token:
|
|
204
215
|
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
205
216
|
raise typer.Exit(code=1)
|
|
206
217
|
|
|
207
218
|
async def _list():
|
|
208
|
-
api = APIClient()
|
|
219
|
+
api = APIClient(token=token)
|
|
209
220
|
return await api.get("/v1/api-keys")
|
|
210
221
|
|
|
211
222
|
try:
|
|
@@ -244,9 +255,12 @@ def apikey_list():
|
|
|
244
255
|
def apikey_revoke(
|
|
245
256
|
key_id: str = typer.Argument(..., help="Key ID to revoke (e.g. key-a3b2c1d4)"),
|
|
246
257
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
258
|
+
user: Optional[str] = typer.Option(
|
|
259
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
260
|
+
),
|
|
247
261
|
):
|
|
248
262
|
"""Revoke an API key immediately."""
|
|
249
|
-
token =
|
|
263
|
+
token = resolve_credentials(user=user)
|
|
250
264
|
if not token:
|
|
251
265
|
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
252
266
|
raise typer.Exit(code=1)
|
|
@@ -261,7 +275,7 @@ def apikey_revoke(
|
|
|
261
275
|
return
|
|
262
276
|
|
|
263
277
|
async def _revoke():
|
|
264
|
-
api = APIClient()
|
|
278
|
+
api = APIClient(token=token)
|
|
265
279
|
return await api.delete(f"/v1/api-keys/{key_id}")
|
|
266
280
|
|
|
267
281
|
try:
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import typer
|
|
3
|
+
import webbrowser
|
|
4
|
+
import secrets
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
import asyncio
|
|
8
|
+
import httpx
|
|
9
|
+
from ..core.auth_server import start_callback_server, find_available_port
|
|
10
|
+
from ..core.api_client import APIClient
|
|
11
|
+
from ..core.config import (
|
|
12
|
+
load_global_config,
|
|
13
|
+
get_access_token,
|
|
14
|
+
delete_access_token,
|
|
15
|
+
load_project_config,
|
|
16
|
+
save_project_config,
|
|
17
|
+
register_user,
|
|
18
|
+
resolve_credentials,
|
|
19
|
+
get_token_for_user,
|
|
20
|
+
ProjectConfig,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = typer.Typer()
|
|
24
|
+
console = Console()
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
API_URL = os.getenv("FLOWSTASH_API_URL", "https://api.flowstash.dev")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _fetch_user_info(token: str) -> Optional[dict]:
|
|
31
|
+
"""Fetch /v1/auth/me using an explicit token (used right after login)."""
|
|
32
|
+
try:
|
|
33
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
34
|
+
resp = await client.get(
|
|
35
|
+
f"{API_URL}/v1/auth/me",
|
|
36
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
37
|
+
)
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
return resp.json()
|
|
40
|
+
except Exception:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def login(
|
|
46
|
+
username: Optional[str] = typer.Option(
|
|
47
|
+
None, "--username", "-u", help="Username / email for manual login"
|
|
48
|
+
),
|
|
49
|
+
password: Optional[str] = typer.Option(
|
|
50
|
+
None, "--password", "-p", help="Password for manual login"
|
|
51
|
+
),
|
|
52
|
+
):
|
|
53
|
+
"""Log in to the flowstash Managed Platform.
|
|
54
|
+
|
|
55
|
+
Multiple accounts are supported — logging in as a new user does NOT
|
|
56
|
+
log out existing sessions. Use [bold]flowstash accounts[/bold] to see
|
|
57
|
+
all active sessions.
|
|
58
|
+
"""
|
|
59
|
+
# ── Existing-account check ──────────────────────────────────────────────
|
|
60
|
+
global_config = load_global_config()
|
|
61
|
+
if global_config.accounts:
|
|
62
|
+
console.print("[bold]You already have logged-in accounts:[/bold]")
|
|
63
|
+
for i, acc in enumerate(global_config.accounts, 1):
|
|
64
|
+
label = (
|
|
65
|
+
f"{acc.display_name} ({acc.email})" if acc.display_name else acc.email
|
|
66
|
+
)
|
|
67
|
+
console.print(f" {i}. {label}")
|
|
68
|
+
new_idx = len(global_config.accounts) + 1
|
|
69
|
+
console.print(f" {new_idx}. Login as a new user")
|
|
70
|
+
|
|
71
|
+
raw = typer.prompt("Select an option", default=str(new_idx))
|
|
72
|
+
try:
|
|
73
|
+
choice = int(raw)
|
|
74
|
+
except ValueError:
|
|
75
|
+
choice = new_idx
|
|
76
|
+
|
|
77
|
+
if 1 <= choice <= len(global_config.accounts):
|
|
78
|
+
selected = global_config.accounts[choice - 1]
|
|
79
|
+
project_config = load_project_config() or ProjectConfig()
|
|
80
|
+
project_config.linked_user = selected.email
|
|
81
|
+
save_project_config(project_config)
|
|
82
|
+
console.print(
|
|
83
|
+
f"[green]Project account set to: [bold]{selected.email}[/bold][/green]"
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
# else: fall through to normal login flow
|
|
87
|
+
|
|
88
|
+
if username and password:
|
|
89
|
+
console.print(f"Logging in to {API_URL} as {username}...")
|
|
90
|
+
|
|
91
|
+
async def do_login():
|
|
92
|
+
async with httpx.AsyncClient() as client:
|
|
93
|
+
try:
|
|
94
|
+
resp = await client.post(
|
|
95
|
+
f"{API_URL}/v1/auth/login",
|
|
96
|
+
json={"email": username, "password": password},
|
|
97
|
+
)
|
|
98
|
+
resp.raise_for_status()
|
|
99
|
+
return resp.json()
|
|
100
|
+
except Exception as e:
|
|
101
|
+
console.print(f"[red]Login failed: {e}[/red]")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
result = asyncio.run(do_login())
|
|
105
|
+
if not result:
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
access_token = result.get("access_token")
|
|
109
|
+
if not access_token:
|
|
110
|
+
console.print("[red]No access token in response.[/red]")
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
# Resolve the canonical email from the API (may differ from what was typed)
|
|
114
|
+
user_info = asyncio.run(_fetch_user_info(access_token))
|
|
115
|
+
email = (
|
|
116
|
+
(user_info.get("email") or user_info.get("username") or username)
|
|
117
|
+
if user_info
|
|
118
|
+
else username
|
|
119
|
+
)
|
|
120
|
+
tenant_id = (user_info or result).get("tenant_id")
|
|
121
|
+
display_name = user_info.get("name") if user_info else None
|
|
122
|
+
|
|
123
|
+
register_user(
|
|
124
|
+
email, access_token, display_name=display_name, tenant_id=tenant_id
|
|
125
|
+
)
|
|
126
|
+
console.print(
|
|
127
|
+
f"[green]Logged in as: [bold]{email}[/bold] (tenant: {tenant_id})[/green]"
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Browser / OAuth login
|
|
132
|
+
state = secrets.token_urlsafe(16)
|
|
133
|
+
try:
|
|
134
|
+
port = find_available_port(8500)
|
|
135
|
+
except RuntimeError as exc:
|
|
136
|
+
console.print(f"[red]{exc}[/red]")
|
|
137
|
+
raise typer.Exit(code=1)
|
|
138
|
+
callback_url = f"http://localhost:{port}/callback"
|
|
139
|
+
login_url = (
|
|
140
|
+
API_URL.replace("api", "app")
|
|
141
|
+
+ f"/cli-login?redirect_uri={callback_url}&state={state}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
console.print("Opening your browser to authenticate...")
|
|
145
|
+
console.print(
|
|
146
|
+
f"If the browser doesn't open, visit: [link={login_url}]{login_url}[/link]"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
webbrowser.open(login_url)
|
|
150
|
+
|
|
151
|
+
console.print("Waiting for authentication...")
|
|
152
|
+
result = start_callback_server(port)
|
|
153
|
+
|
|
154
|
+
if not result:
|
|
155
|
+
console.print("[red]Authentication timed out or failed.[/red]")
|
|
156
|
+
raise typer.Exit(code=1)
|
|
157
|
+
|
|
158
|
+
if result.get("state") != state:
|
|
159
|
+
console.print(
|
|
160
|
+
"[red]Invalid state received. Auth session might be compromised.[/red]"
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
access_token = result.get("access_token")
|
|
165
|
+
if not access_token:
|
|
166
|
+
console.print("[red]No access token received.[/red]")
|
|
167
|
+
raise typer.Exit(code=1)
|
|
168
|
+
|
|
169
|
+
user_info = asyncio.run(_fetch_user_info(access_token))
|
|
170
|
+
email = user_info.get("email") or user_info.get("username") if user_info else None
|
|
171
|
+
tenant_id = (user_info or result).get("tenant_id")
|
|
172
|
+
display_name = user_info.get("name") if user_info else None
|
|
173
|
+
|
|
174
|
+
if not email:
|
|
175
|
+
print(user_info)
|
|
176
|
+
console.print(
|
|
177
|
+
"[red]Could not determine account email from server response.[/red]"
|
|
178
|
+
)
|
|
179
|
+
raise typer.Exit(code=1)
|
|
180
|
+
|
|
181
|
+
register_user(email, access_token, display_name=display_name, tenant_id=tenant_id)
|
|
182
|
+
console.print(
|
|
183
|
+
f"[green]Logged in as: [bold]{email}[/bold] (tenant: {tenant_id})[/green]"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def logout(
|
|
189
|
+
user: Optional[str] = typer.Option(
|
|
190
|
+
None, "--user", "-u", help="Account email to log out (default: current user)"
|
|
191
|
+
),
|
|
192
|
+
):
|
|
193
|
+
"""Log out from the flowstash Managed Platform.
|
|
194
|
+
|
|
195
|
+
Pass [bold]--user[/bold] to log out a specific account while keeping
|
|
196
|
+
other sessions active.
|
|
197
|
+
"""
|
|
198
|
+
delete_access_token(email=user)
|
|
199
|
+
label = f" ({user})" if user else ""
|
|
200
|
+
console.print(f"[yellow]Logged out{label} successfully.[/yellow]")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def accounts():
|
|
205
|
+
"""List all logged-in accounts."""
|
|
206
|
+
config = load_global_config()
|
|
207
|
+
if not config.accounts:
|
|
208
|
+
console.print("No accounts logged in. Run [bold]flowstash login[/bold] first.")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
table = Table(
|
|
212
|
+
title="Logged-in Accounts", show_header=True, header_style="bold cyan"
|
|
213
|
+
)
|
|
214
|
+
table.add_column("Email")
|
|
215
|
+
table.add_column("Display Name")
|
|
216
|
+
table.add_column("Tenant")
|
|
217
|
+
table.add_column("Active", justify="center")
|
|
218
|
+
|
|
219
|
+
for acc in config.accounts:
|
|
220
|
+
is_current = "[green]✓[/green]" if acc.email == config.current_user else ""
|
|
221
|
+
table.add_row(
|
|
222
|
+
acc.email,
|
|
223
|
+
acc.display_name or "[dim]—[/dim]",
|
|
224
|
+
acc.tenant_id or "[dim]—[/dim]",
|
|
225
|
+
is_current,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
console.print(table)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _fetch_current_user(token: Optional[str] = None) -> Optional[dict]:
|
|
232
|
+
try:
|
|
233
|
+
client = APIClient(token=token) if token else APIClient()
|
|
234
|
+
return asyncio.run(client.get("/v1/auth/me"))
|
|
235
|
+
except Exception:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
def whoami(
|
|
241
|
+
user: Optional[str] = typer.Option(
|
|
242
|
+
None, "--user", "-u", help="Show status for a specific account"
|
|
243
|
+
),
|
|
244
|
+
):
|
|
245
|
+
"""Show current login status."""
|
|
246
|
+
token = resolve_credentials(user=user)
|
|
247
|
+
global_config = load_global_config()
|
|
248
|
+
project_config = load_project_config()
|
|
249
|
+
|
|
250
|
+
if token:
|
|
251
|
+
console.print(f"API URL: {global_config.api_url}")
|
|
252
|
+
user_info = _fetch_current_user(token=token)
|
|
253
|
+
login = None
|
|
254
|
+
tenant_id = None
|
|
255
|
+
|
|
256
|
+
if user_info:
|
|
257
|
+
login = (
|
|
258
|
+
user_info.get("email")
|
|
259
|
+
or user_info.get("username")
|
|
260
|
+
or user_info.get("login")
|
|
261
|
+
)
|
|
262
|
+
tenant_id = user_info.get("tenant_id")
|
|
263
|
+
elif project_config:
|
|
264
|
+
login = project_config.user_email
|
|
265
|
+
tenant_id = project_config.tenant_id
|
|
266
|
+
|
|
267
|
+
if login:
|
|
268
|
+
console.print(f"Logged in as: [bold]{login}[/bold]")
|
|
269
|
+
if tenant_id:
|
|
270
|
+
console.print(f"Tenant: [bold]{tenant_id}[/bold]")
|
|
271
|
+
|
|
272
|
+
if project_config:
|
|
273
|
+
console.print(f"Current Project: [bold]{project_config.project_id}[/bold]")
|
|
274
|
+
if project_config.linked_user:
|
|
275
|
+
console.print(
|
|
276
|
+
f"Project Account: [bold]{project_config.linked_user}[/bold]"
|
|
277
|
+
)
|
|
278
|
+
elif not user_info:
|
|
279
|
+
console.print("Logged in, but no project context found in this directory.")
|
|
280
|
+
|
|
281
|
+
# Show all sessions summary
|
|
282
|
+
if global_config.accounts and len(global_config.accounts) > 1:
|
|
283
|
+
other = [a.email for a in global_config.accounts if a.email != login]
|
|
284
|
+
console.print(f"[dim]Other active sessions: {', '.join(other)}[/dim]")
|
|
285
|
+
else:
|
|
286
|
+
console.print("Not logged in.")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from typing import Optional
|
|
1
2
|
import typer
|
|
2
3
|
import asyncio
|
|
3
4
|
import time
|
|
@@ -5,7 +6,7 @@ from pathlib import Path
|
|
|
5
6
|
from rich.console import Console
|
|
6
7
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
8
|
from ..core.api_client import APIClient
|
|
8
|
-
from ..core.config import load_project_config
|
|
9
|
+
from ..core.config import load_project_config, resolve_credentials
|
|
9
10
|
from ..core.builder import bundle_source
|
|
10
11
|
|
|
11
12
|
import subprocess
|
|
@@ -21,7 +22,7 @@ from ..core.docker_utils import (
|
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
24
|
-
async def run_managed_build(tag: str = "latest"):
|
|
25
|
+
async def run_managed_build(tag: str = "latest", user: Optional[str] = None):
|
|
25
26
|
project_config = load_project_config()
|
|
26
27
|
if not project_config or not project_config.project_id:
|
|
27
28
|
console.print(
|
|
@@ -29,7 +30,12 @@ async def run_managed_build(tag: str = "latest"):
|
|
|
29
30
|
)
|
|
30
31
|
raise typer.Exit(code=1)
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
token = resolve_credentials(user=user)
|
|
34
|
+
if not token:
|
|
35
|
+
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
|
|
38
|
+
api = APIClient(token=token)
|
|
33
39
|
# ... rest of existing managed build logic ...
|
|
34
40
|
# (I'll keep the existing implementation but wrap it)
|
|
35
41
|
|
|
@@ -38,6 +44,9 @@ async def run_managed_build(tag: str = "latest"):
|
|
|
38
44
|
def build(
|
|
39
45
|
env: str = typer.Argument(..., help="Environment to build"),
|
|
40
46
|
tag: str = typer.Option("latest", "--tag", "-t", help="Tag for the image"),
|
|
47
|
+
user: Optional[str] = typer.Option(
|
|
48
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
49
|
+
),
|
|
41
50
|
):
|
|
42
51
|
"""Build project artifacts/images for the specified environment."""
|
|
43
52
|
project_config = load_project_config()
|
|
@@ -51,7 +60,7 @@ def build(
|
|
|
51
60
|
break
|
|
52
61
|
|
|
53
62
|
if is_managed:
|
|
54
|
-
result = asyncio.run(run_build_flow(tag))
|
|
63
|
+
result = asyncio.run(run_build_flow(tag, user=user))
|
|
55
64
|
console.print(f"[green]Managed build completed successfully![/green]")
|
|
56
65
|
console.print(f"Artifact ID: [bold]{result['artifact_id']}[/bold]")
|
|
57
66
|
else:
|
|
@@ -82,11 +91,15 @@ def build(
|
|
|
82
91
|
raise typer.Exit(code=1)
|
|
83
92
|
|
|
84
93
|
|
|
85
|
-
async def run_build_flow(tag: str = "latest"):
|
|
94
|
+
async def run_build_flow(tag: str = "latest", user: Optional[str] = None):
|
|
86
95
|
# (Moved existing run_build_flow logic here for completeness in the file)
|
|
87
96
|
project_config = load_project_config()
|
|
88
97
|
project_id = project_config.project_id
|
|
89
|
-
|
|
98
|
+
token = resolve_credentials(user=user)
|
|
99
|
+
if not token:
|
|
100
|
+
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
api = APIClient(token=token)
|
|
90
103
|
|
|
91
104
|
try:
|
|
92
105
|
with Progress(
|
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
from rich.console import Console
|
|
6
6
|
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
7
7
|
from ..core.api_client import APIClient
|
|
8
|
-
from ..core.config import load_project_config
|
|
8
|
+
from ..core.config import load_project_config, resolve_credentials
|
|
9
9
|
from .build import run_build_flow
|
|
10
10
|
import flowstash.runtime
|
|
11
11
|
|
|
@@ -26,7 +26,9 @@ _STATUS_LABELS = {
|
|
|
26
26
|
_TERMINAL_STATUSES = {"DEPLOYED", "FAILED"}
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
async def run_deploy_flow(
|
|
29
|
+
async def run_deploy_flow(
|
|
30
|
+
env: str, artifact_id: Optional[str] = None, user: Optional[str] = None
|
|
31
|
+
):
|
|
30
32
|
project_config = load_project_config()
|
|
31
33
|
if not project_config:
|
|
32
34
|
console.print(
|
|
@@ -39,12 +41,17 @@ async def run_deploy_flow(env: str, artifact_id: Optional[str] = None):
|
|
|
39
41
|
console.print("[red]project_id not found in .flowstash[/red]")
|
|
40
42
|
raise typer.Exit(code=1)
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
token = resolve_credentials(user=user)
|
|
45
|
+
if not token:
|
|
46
|
+
console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
api = APIClient(token=token)
|
|
43
50
|
|
|
44
51
|
# 1. If artifact_id is not provided, run build first
|
|
45
52
|
if not artifact_id:
|
|
46
53
|
console.print("No artifact ID provided. Building first...")
|
|
47
|
-
build_result = await run_build_flow()
|
|
54
|
+
build_result = await run_build_flow(user=user)
|
|
48
55
|
artifact_id = build_result["artifact_id"]
|
|
49
56
|
console.print(f"Build finished. Deploying artifact: [bold]{artifact_id}[/bold]")
|
|
50
57
|
|
|
@@ -97,6 +104,9 @@ def deploy(
|
|
|
97
104
|
None, "--artifact", "-a", help="Artifact ID to deploy"
|
|
98
105
|
),
|
|
99
106
|
yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation"),
|
|
107
|
+
user: Optional[str] = typer.Option(
|
|
108
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
109
|
+
),
|
|
100
110
|
):
|
|
101
111
|
"""Deploy an artifact to the managed platform for a specified environment."""
|
|
102
112
|
project_config = load_project_config()
|
|
@@ -140,7 +150,7 @@ def deploy(
|
|
|
140
150
|
):
|
|
141
151
|
from .project import _link_project
|
|
142
152
|
|
|
143
|
-
_link_project(project_config)
|
|
153
|
+
_link_project(project_config, user=user)
|
|
144
154
|
project_id = project_config.project_id
|
|
145
155
|
|
|
146
156
|
if not project_id:
|
|
@@ -149,7 +159,7 @@ def deploy(
|
|
|
149
159
|
)
|
|
150
160
|
raise typer.Exit(code=1)
|
|
151
161
|
|
|
152
|
-
result = asyncio.run(run_deploy_flow(env, artifact))
|
|
162
|
+
result = asyncio.run(run_deploy_flow(env, artifact, user=user))
|
|
153
163
|
|
|
154
164
|
api_url = result.get("api_url", "")
|
|
155
165
|
console.print(f"[green]✓ Deployment complete![/green]")
|
|
@@ -11,6 +11,7 @@ import questionary
|
|
|
11
11
|
from ..core.api_client import APIClient
|
|
12
12
|
from ..core.config import (
|
|
13
13
|
get_access_token,
|
|
14
|
+
resolve_credentials,
|
|
14
15
|
load_project_config,
|
|
15
16
|
save_project_config,
|
|
16
17
|
ProjectConfig,
|
|
@@ -714,15 +715,15 @@ def init(
|
|
|
714
715
|
console.print("[green]Initialization/Repair complete![/green]")
|
|
715
716
|
|
|
716
717
|
|
|
717
|
-
def _link_project(config: ProjectConfig):
|
|
718
|
-
token =
|
|
718
|
+
def _link_project(config: ProjectConfig, user: Optional[str] = None):
|
|
719
|
+
token = resolve_credentials(user=user)
|
|
719
720
|
if not token:
|
|
720
721
|
console.print(
|
|
721
722
|
"[yellow]Not logged in. Use 'flowstash login' to link to a managed project.[/yellow]"
|
|
722
723
|
)
|
|
723
724
|
return
|
|
724
725
|
|
|
725
|
-
api = APIClient()
|
|
726
|
+
api = APIClient(token=token)
|
|
726
727
|
try:
|
|
727
728
|
projects_data = asyncio.run(api.get("/v1/projects"))
|
|
728
729
|
projects = projects_data.get("projects", [])
|
|
@@ -756,7 +757,8 @@ def _link_project(config: ProjectConfig):
|
|
|
756
757
|
user_info = asyncio.run(api.get("/v1/auth/me"))
|
|
757
758
|
config.tenant_id = user_info["tenant_id"]
|
|
758
759
|
config.user_email = user_info.get("email")
|
|
759
|
-
|
|
760
|
+
config.linked_user = user_info.get("email") # pin project to this account
|
|
761
|
+
except Exception:
|
|
760
762
|
config.tenant_id = "default"
|
|
761
763
|
|
|
762
764
|
config.project_id = project_id
|
|
@@ -4,13 +4,14 @@ from .config import load_global_config, get_access_token
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class APIClient:
|
|
7
|
-
def __init__(self):
|
|
7
|
+
def __init__(self, token: Optional[str] = None):
|
|
8
8
|
self.config = load_global_config()
|
|
9
9
|
self.base_url = self.config.api_url.rstrip("/")
|
|
10
|
+
self._token = token # explicit token takes priority over resolved one
|
|
10
11
|
|
|
11
12
|
def _get_headers(self) -> Dict[str, str]:
|
|
12
13
|
headers = {}
|
|
13
|
-
token = get_access_token()
|
|
14
|
+
token = self._token if self._token is not None else get_access_token()
|
|
14
15
|
if token:
|
|
15
16
|
headers["Authorization"] = f"Bearer {token}"
|
|
16
17
|
return headers
|
|
@@ -23,7 +24,9 @@ class APIClient:
|
|
|
23
24
|
response.raise_for_status()
|
|
24
25
|
return response.json()
|
|
25
26
|
|
|
26
|
-
async def post(
|
|
27
|
+
async def post(
|
|
28
|
+
self, path: str, json: Optional[Dict[str, Any]] = None, timeout: float = 30.0
|
|
29
|
+
):
|
|
27
30
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
28
31
|
response = await client.post(
|
|
29
32
|
f"{self.base_url}{path}", json=json, headers=self._get_headers()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Dict, Optional
|
|
2
2
|
import threading
|
|
3
3
|
import queue
|
|
4
|
+
import socket
|
|
4
5
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
5
6
|
from urllib.parse import urlparse, parse_qs
|
|
6
7
|
|
|
@@ -30,6 +31,21 @@ class CallbackHandler(BaseHTTPRequestHandler):
|
|
|
30
31
|
# Suppress logging
|
|
31
32
|
pass
|
|
32
33
|
|
|
34
|
+
def find_available_port(start: int = 8500, max_attempts: int = 10) -> int:
|
|
35
|
+
"""Return the first free TCP port starting from *start*, trying up to *max_attempts* ports."""
|
|
36
|
+
for offset in range(max_attempts):
|
|
37
|
+
port = start + offset
|
|
38
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
39
|
+
try:
|
|
40
|
+
s.bind(("127.0.0.1", port))
|
|
41
|
+
return port
|
|
42
|
+
except OSError:
|
|
43
|
+
continue
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"No available port found in range {start}–{start + max_attempts - 1}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
33
49
|
def start_callback_server(port: int = 8888) -> Dict[str, str]:
|
|
34
50
|
server = HTTPServer(('127.0.0.1', port), CallbackHandler)
|
|
35
51
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
import yaml
|
|
7
|
+
import keyring
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
flowstash_DIR = Path.home() / ".flowstash"
|
|
11
|
+
GLOBAL_CONFIG_FILE = flowstash_DIR / "config.yaml"
|
|
12
|
+
PROJECT_CONFIG_FILE = ".flowstash"
|
|
13
|
+
|
|
14
|
+
SERVICE_NAME = "flowstash"
|
|
15
|
+
TOKEN_KEY = "access_token" # legacy key – kept for migration only
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserAccount(BaseModel):
|
|
19
|
+
email: str
|
|
20
|
+
display_name: Optional[str] = None
|
|
21
|
+
tenant_id: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GlobalConfig(BaseModel):
|
|
25
|
+
api_url: str = os.getenv("FLOWSTASH_API_URL", "https://api.flowstash.dev")
|
|
26
|
+
accounts: List[UserAccount] = Field(default_factory=list)
|
|
27
|
+
current_user: Optional[str] = None # email of last-used account
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnvironmentMode(BaseModel):
|
|
31
|
+
name: str
|
|
32
|
+
managed: bool = False
|
|
33
|
+
options: Dict[str, str] = Field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProjectConfig(BaseModel):
|
|
37
|
+
project_name: Optional[str] = None
|
|
38
|
+
project_id: Optional[str] = None
|
|
39
|
+
tenant_id: Optional[str] = None
|
|
40
|
+
user_email: Optional[str] = None
|
|
41
|
+
linked_user: Optional[str] = None # email pinned to this project
|
|
42
|
+
environments: List[EnvironmentMode] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_global_config() -> GlobalConfig:
|
|
46
|
+
if not GLOBAL_CONFIG_FILE.exists():
|
|
47
|
+
return GlobalConfig()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with open(GLOBAL_CONFIG_FILE, "r") as f:
|
|
51
|
+
data = yaml.safe_load(f) or {}
|
|
52
|
+
return GlobalConfig(**data)
|
|
53
|
+
except Exception:
|
|
54
|
+
return GlobalConfig()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_global_config(config: GlobalConfig):
|
|
58
|
+
flowstash_DIR.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
with open(GLOBAL_CONFIG_FILE, "w") as f:
|
|
60
|
+
yaml.safe_dump(config.model_dump(), f)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Per-user token helpers
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _user_token_key(email: str) -> str:
|
|
69
|
+
return f"token:{email}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_token_for_user(email: str, token: str) -> None:
|
|
73
|
+
keyring.set_password(SERVICE_NAME, _user_token_key(email), token)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_token_for_user(email: str) -> Optional[str]:
|
|
77
|
+
return keyring.get_password(SERVICE_NAME, _user_token_key(email))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete_token_for_user(email: str) -> None:
|
|
81
|
+
try:
|
|
82
|
+
keyring.delete_password(SERVICE_NAME, _user_token_key(email))
|
|
83
|
+
except keyring.errors.PasswordDeleteError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def register_user(
|
|
88
|
+
email: str,
|
|
89
|
+
token: str,
|
|
90
|
+
display_name: Optional[str] = None,
|
|
91
|
+
tenant_id: Optional[str] = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Persist a new (or updated) login session without evicting other users."""
|
|
94
|
+
set_token_for_user(email, token)
|
|
95
|
+
config = load_global_config()
|
|
96
|
+
existing = next((a for a in config.accounts if a.email == email), None)
|
|
97
|
+
if existing:
|
|
98
|
+
if display_name:
|
|
99
|
+
existing.display_name = display_name
|
|
100
|
+
if tenant_id:
|
|
101
|
+
existing.tenant_id = tenant_id
|
|
102
|
+
else:
|
|
103
|
+
config.accounts.append(
|
|
104
|
+
UserAccount(email=email, display_name=display_name, tenant_id=tenant_id)
|
|
105
|
+
)
|
|
106
|
+
config.current_user = email
|
|
107
|
+
save_global_config(config)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_credentials(user: Optional[str] = None) -> Optional[str]:
|
|
111
|
+
"""
|
|
112
|
+
Resolve an access token using the priority chain:
|
|
113
|
+
1. Explicit user (--user flag)
|
|
114
|
+
2. Project-linked user (.flowstash linked_user)
|
|
115
|
+
3. Global current_user (last logged-in)
|
|
116
|
+
4. Legacy 'access_token' keyring key (backward compat)
|
|
117
|
+
5. FLOWSTASH_USER env var acts as implicit --user when set
|
|
118
|
+
"""
|
|
119
|
+
# env-var override (useful for CI)
|
|
120
|
+
effective_user = user or os.getenv("FLOWSTASH_USER")
|
|
121
|
+
if effective_user:
|
|
122
|
+
return get_token_for_user(effective_user)
|
|
123
|
+
|
|
124
|
+
project_config = load_project_config()
|
|
125
|
+
if project_config and project_config.linked_user:
|
|
126
|
+
token = get_token_for_user(project_config.linked_user)
|
|
127
|
+
if token:
|
|
128
|
+
return token
|
|
129
|
+
|
|
130
|
+
global_config = load_global_config()
|
|
131
|
+
if global_config.current_user:
|
|
132
|
+
token = get_token_for_user(global_config.current_user)
|
|
133
|
+
if token:
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
# Legacy migration path
|
|
137
|
+
return keyring.get_password(SERVICE_NAME, TOKEN_KEY)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Legacy helpers (kept for backward compat / tests)
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_access_token() -> Optional[str]:
|
|
146
|
+
"""Backward-compatible accessor. Prefer resolve_credentials() for new code."""
|
|
147
|
+
return resolve_credentials()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def set_access_token(token: str) -> None:
|
|
151
|
+
"""Legacy setter – stores under the old single-token key only."""
|
|
152
|
+
keyring.set_password(SERVICE_NAME, TOKEN_KEY, token)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def delete_access_token(email: Optional[str] = None) -> None:
|
|
156
|
+
"""Log out one user (or the current user when email is None)."""
|
|
157
|
+
if not email:
|
|
158
|
+
config = load_global_config()
|
|
159
|
+
email = config.current_user
|
|
160
|
+
|
|
161
|
+
if email:
|
|
162
|
+
delete_token_for_user(email)
|
|
163
|
+
config = load_global_config()
|
|
164
|
+
config.accounts = [a for a in config.accounts if a.email != email]
|
|
165
|
+
if config.current_user == email:
|
|
166
|
+
config.current_user = config.accounts[-1].email if config.accounts else None
|
|
167
|
+
save_global_config(config)
|
|
168
|
+
else:
|
|
169
|
+
# Legacy fallback
|
|
170
|
+
try:
|
|
171
|
+
keyring.delete_password(SERVICE_NAME, TOKEN_KEY)
|
|
172
|
+
except keyring.errors.PasswordDeleteError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_project_config() -> Optional[ProjectConfig]:
|
|
177
|
+
path = Path(PROJECT_CONFIG_FILE)
|
|
178
|
+
if not path.exists():
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with open(path, "r") as f:
|
|
183
|
+
data = yaml.safe_load(f) or {}
|
|
184
|
+
return ProjectConfig(**data)
|
|
185
|
+
except Exception:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def save_project_config(config: ProjectConfig):
|
|
190
|
+
with open(PROJECT_CONFIG_FILE, "w") as f:
|
|
191
|
+
yaml.safe_dump(config.model_dump(), f)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Legacy aliases
|
|
195
|
+
def load_config() -> GlobalConfig:
|
|
196
|
+
return load_global_config()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def save_config(config: GlobalConfig):
|
|
200
|
+
save_global_config(config)
|
|
@@ -242,6 +242,9 @@ def build(
|
|
|
242
242
|
env: str = typer.Argument("dev", help="Environment to build (default: local)"),
|
|
243
243
|
tag: str = typer.Option("latest", "--tag", "-t", help="Tag for the image"),
|
|
244
244
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
|
|
245
|
+
user: Optional[str] = typer.Option(
|
|
246
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
247
|
+
),
|
|
245
248
|
):
|
|
246
249
|
"""
|
|
247
250
|
[bold yellow]Build[/bold yellow] project artifacts/images.
|
|
@@ -276,7 +279,7 @@ def build(
|
|
|
276
279
|
console.print(f"[red]Environment '{env}' not found.[/red]")
|
|
277
280
|
raise typer.Exit(code=1)
|
|
278
281
|
|
|
279
|
-
build_cmds.build(env=env, tag=tag)
|
|
282
|
+
build_cmds.build(env=env, tag=tag, user=user)
|
|
280
283
|
|
|
281
284
|
|
|
282
285
|
@app.command()
|
|
@@ -287,6 +290,9 @@ def deploy(
|
|
|
287
290
|
None, "--artifact", "-a", help="Artifact ID to deploy"
|
|
288
291
|
),
|
|
289
292
|
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
|
|
293
|
+
user: Optional[str] = typer.Option(
|
|
294
|
+
None, "--user", "-u", help="Account to use (default: project-linked or current)"
|
|
295
|
+
),
|
|
290
296
|
):
|
|
291
297
|
"""
|
|
292
298
|
[bold cyan]Deploy[/bold cyan] your project to the flowstash Managed Platform.
|
|
@@ -347,25 +353,43 @@ def deploy(
|
|
|
347
353
|
)
|
|
348
354
|
raise typer.Exit(code=1)
|
|
349
355
|
|
|
350
|
-
deploy_cmds.deploy(env=env, artifact=artifact, yes=yes)
|
|
356
|
+
deploy_cmds.deploy(env=env, artifact=artifact, yes=yes, user=user)
|
|
351
357
|
|
|
352
358
|
|
|
353
359
|
@app.command()
|
|
354
|
-
def whoami(
|
|
360
|
+
def whoami(
|
|
361
|
+
user: Optional[str] = typer.Option(
|
|
362
|
+
None, "--user", "-u", help="Show status for a specific account"
|
|
363
|
+
),
|
|
364
|
+
):
|
|
355
365
|
"""Show current login status."""
|
|
356
|
-
auth_cmds.whoami()
|
|
366
|
+
auth_cmds.whoami(user=user)
|
|
357
367
|
|
|
358
368
|
|
|
359
369
|
@app.command("logged-in")
|
|
360
|
-
def logged_in(
|
|
370
|
+
def logged_in(
|
|
371
|
+
user: Optional[str] = typer.Option(
|
|
372
|
+
None, "--user", "-u", help="Show status for a specific account"
|
|
373
|
+
),
|
|
374
|
+
):
|
|
361
375
|
"""Show who is currently logged in."""
|
|
362
|
-
auth_cmds.whoami()
|
|
376
|
+
auth_cmds.whoami(user=user)
|
|
363
377
|
|
|
364
378
|
|
|
365
379
|
@app.command()
|
|
366
|
-
def logout(
|
|
380
|
+
def logout(
|
|
381
|
+
user: Optional[str] = typer.Option(
|
|
382
|
+
None, "--user", "-u", help="Account email to log out (default: current user)"
|
|
383
|
+
),
|
|
384
|
+
):
|
|
367
385
|
"""Log out from the platform."""
|
|
368
|
-
auth_cmds.logout()
|
|
386
|
+
auth_cmds.logout(user=user)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.command()
|
|
390
|
+
def accounts():
|
|
391
|
+
"""List all logged-in accounts."""
|
|
392
|
+
auth_cmds.accounts()
|
|
369
393
|
|
|
370
394
|
|
|
371
395
|
@app.command()
|
|
@@ -1,151 +0,0 @@
|
|
|
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.api_client import APIClient
|
|
11
|
-
from ..core.config import (
|
|
12
|
-
load_global_config,
|
|
13
|
-
set_access_token,
|
|
14
|
-
get_access_token,
|
|
15
|
-
delete_access_token,
|
|
16
|
-
load_project_config,
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
app = typer.Typer()
|
|
20
|
-
console = Console()
|
|
21
|
-
import os
|
|
22
|
-
|
|
23
|
-
API_URL = os.getenv("FLOWSTASH_API_URL", "https://api.flowstash.dev")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@app.command()
|
|
27
|
-
def login(
|
|
28
|
-
username: Optional[str] = typer.Option(
|
|
29
|
-
None, "--username", "-u", help="Username for manual login"
|
|
30
|
-
),
|
|
31
|
-
password: Optional[str] = typer.Option(
|
|
32
|
-
None, "--password", "-p", help="Password for manual login"
|
|
33
|
-
),
|
|
34
|
-
):
|
|
35
|
-
"""Log in to the flowstash Managed Platform."""
|
|
36
|
-
if username and password:
|
|
37
|
-
# Manual login
|
|
38
|
-
console.print(f"Logging in to {API_URL} as {username}...")
|
|
39
|
-
|
|
40
|
-
async def do_login():
|
|
41
|
-
async with httpx.AsyncClient() as client:
|
|
42
|
-
try:
|
|
43
|
-
resp = await client.post(
|
|
44
|
-
f"{API_URL}/v1/auth/login",
|
|
45
|
-
json={"email": username, "password": password},
|
|
46
|
-
)
|
|
47
|
-
resp.raise_for_status()
|
|
48
|
-
return resp.json()
|
|
49
|
-
except Exception as e:
|
|
50
|
-
console.print(f"[red]Login failed: {e}[/red]")
|
|
51
|
-
return None
|
|
52
|
-
|
|
53
|
-
result = asyncio.run(do_login())
|
|
54
|
-
if not result:
|
|
55
|
-
raise typer.Exit(code=1)
|
|
56
|
-
|
|
57
|
-
access_token = result.get("access_token")
|
|
58
|
-
tenant_id = result.get("tenant_id")
|
|
59
|
-
|
|
60
|
-
set_access_token(access_token)
|
|
61
|
-
console.print(f"[green]Successfully logged in as tenant: {tenant_id}[/green]")
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
# Browser login
|
|
65
|
-
state = secrets.token_urlsafe(16)
|
|
66
|
-
port = 8888
|
|
67
|
-
callback_url = f"http://localhost:{port}/callback"
|
|
68
|
-
|
|
69
|
-
login_url = f"{API_URL}/cli-login?redirect_uri={callback_url}&state={state}"
|
|
70
|
-
|
|
71
|
-
console.print(f"Opening your browser to authenticate...")
|
|
72
|
-
console.print(
|
|
73
|
-
f"If the browser doesn't open, visit: [link={login_url}]{login_url}[/link]"
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
webbrowser.open(login_url)
|
|
77
|
-
|
|
78
|
-
console.print("Waiting for authentication...")
|
|
79
|
-
result = start_callback_server(port)
|
|
80
|
-
|
|
81
|
-
if not result:
|
|
82
|
-
console.print("[red]Authentication timed out or failed.[/red]")
|
|
83
|
-
raise typer.Exit(code=1)
|
|
84
|
-
|
|
85
|
-
if result.get("state") != state:
|
|
86
|
-
console.print(
|
|
87
|
-
"[red]Invalid state received. Auth session might be compromised.[/red]"
|
|
88
|
-
)
|
|
89
|
-
raise typer.Exit(code=1)
|
|
90
|
-
|
|
91
|
-
access_token = result.get("access_token")
|
|
92
|
-
tenant_id = result.get("tenant_id")
|
|
93
|
-
|
|
94
|
-
if not access_token:
|
|
95
|
-
console.print("[red]No access token received.[/red]")
|
|
96
|
-
raise typer.Exit(code=1)
|
|
97
|
-
|
|
98
|
-
set_access_token(access_token)
|
|
99
|
-
|
|
100
|
-
console.print(f"[green]Successfully logged in as tenant: {tenant_id}[/green]")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
@app.command()
|
|
104
|
-
def logout():
|
|
105
|
-
"""Log out from the flowstash Managed Platform."""
|
|
106
|
-
delete_access_token()
|
|
107
|
-
console.print("[yellow]Logged out successfully.[/yellow]")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _fetch_current_user() -> Optional[dict]:
|
|
111
|
-
try:
|
|
112
|
-
return asyncio.run(APIClient().get("/v1/auth/me"))
|
|
113
|
-
except Exception:
|
|
114
|
-
return None
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@app.command()
|
|
118
|
-
def whoami():
|
|
119
|
-
"""Show current login status."""
|
|
120
|
-
token = get_access_token()
|
|
121
|
-
global_config = load_global_config()
|
|
122
|
-
project_config = load_project_config()
|
|
123
|
-
|
|
124
|
-
if token:
|
|
125
|
-
console.print(f"API URL: {global_config.api_url}")
|
|
126
|
-
user_info = _fetch_current_user()
|
|
127
|
-
login = None
|
|
128
|
-
tenant_id = None
|
|
129
|
-
|
|
130
|
-
if user_info:
|
|
131
|
-
login = (
|
|
132
|
-
user_info.get("email")
|
|
133
|
-
or user_info.get("username")
|
|
134
|
-
or user_info.get("login")
|
|
135
|
-
)
|
|
136
|
-
tenant_id = user_info.get("tenant_id")
|
|
137
|
-
elif project_config:
|
|
138
|
-
login = project_config.user_email
|
|
139
|
-
tenant_id = project_config.tenant_id
|
|
140
|
-
|
|
141
|
-
if login:
|
|
142
|
-
console.print(f"Logged in as: [bold]{login}[/bold]")
|
|
143
|
-
if tenant_id:
|
|
144
|
-
console.print(f"Tenant: [bold]{tenant_id}[/bold]")
|
|
145
|
-
|
|
146
|
-
if project_config:
|
|
147
|
-
console.print(f"Current Project: [bold]{project_config.project_id}[/bold]")
|
|
148
|
-
elif not user_info:
|
|
149
|
-
console.print("Logged in, but no project context found in this directory.")
|
|
150
|
-
else:
|
|
151
|
-
console.print("Not logged in.")
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
from typing import List
|
|
2
|
-
from pydantic import Field
|
|
3
|
-
import os
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Optional, Dict, Any
|
|
6
|
-
import yaml
|
|
7
|
-
import keyring
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
flowstash_DIR = Path.home() / ".flowstash"
|
|
11
|
-
GLOBAL_CONFIG_FILE = flowstash_DIR / "config.yaml"
|
|
12
|
-
PROJECT_CONFIG_FILE = ".flowstash"
|
|
13
|
-
|
|
14
|
-
SERVICE_NAME = "flowstash"
|
|
15
|
-
TOKEN_KEY = "access_token"
|
|
16
|
-
|
|
17
|
-
class GlobalConfig(BaseModel):
|
|
18
|
-
api_url: str = os.getenv("FLOWSTASH_API_URL", "https://api.flowstash.dev") # Default to managed platform
|
|
19
|
-
|
|
20
|
-
class EnvironmentMode(BaseModel):
|
|
21
|
-
name: str
|
|
22
|
-
managed: bool = False
|
|
23
|
-
options: Dict[str, str] = Field(default_factory=dict)
|
|
24
|
-
|
|
25
|
-
class ProjectConfig(BaseModel):
|
|
26
|
-
project_name: Optional[str] = None
|
|
27
|
-
project_id: Optional[str] = None
|
|
28
|
-
tenant_id: Optional[str] = None
|
|
29
|
-
user_email: Optional[str] = None
|
|
30
|
-
environments: List[EnvironmentMode] = Field(default_factory=list)
|
|
31
|
-
|
|
32
|
-
def load_global_config() -> GlobalConfig:
|
|
33
|
-
if not GLOBAL_CONFIG_FILE.exists():
|
|
34
|
-
return GlobalConfig()
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
with open(GLOBAL_CONFIG_FILE, "r") as f:
|
|
38
|
-
data = yaml.safe_load(f) or {}
|
|
39
|
-
return GlobalConfig(**data)
|
|
40
|
-
except Exception:
|
|
41
|
-
return GlobalConfig()
|
|
42
|
-
|
|
43
|
-
def save_global_config(config: GlobalConfig):
|
|
44
|
-
flowstash_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
-
with open(GLOBAL_CONFIG_FILE, "w") as f:
|
|
46
|
-
yaml.safe_dump(config.model_dump(), f)
|
|
47
|
-
|
|
48
|
-
def get_access_token() -> Optional[str]:
|
|
49
|
-
return keyring.get_password(SERVICE_NAME, TOKEN_KEY)
|
|
50
|
-
|
|
51
|
-
def set_access_token(token: str):
|
|
52
|
-
keyring.set_password(SERVICE_NAME, TOKEN_KEY, token)
|
|
53
|
-
|
|
54
|
-
def delete_access_token():
|
|
55
|
-
try:
|
|
56
|
-
keyring.delete_password(SERVICE_NAME, TOKEN_KEY)
|
|
57
|
-
except keyring.errors.PasswordDeleteError:
|
|
58
|
-
pass
|
|
59
|
-
|
|
60
|
-
def load_project_config() -> Optional[ProjectConfig]:
|
|
61
|
-
path = Path(PROJECT_CONFIG_FILE)
|
|
62
|
-
if not path.exists():
|
|
63
|
-
return None
|
|
64
|
-
|
|
65
|
-
try:
|
|
66
|
-
with open(path, "r") as f:
|
|
67
|
-
data = yaml.safe_load(f) or {}
|
|
68
|
-
return ProjectConfig(**data)
|
|
69
|
-
except Exception:
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
def save_project_config(config: ProjectConfig):
|
|
73
|
-
with open(PROJECT_CONFIG_FILE, "w") as f:
|
|
74
|
-
yaml.safe_dump(config.model_dump(), f)
|
|
75
|
-
|
|
76
|
-
# Legacy aliases for compatibility if needed, though we should update callers
|
|
77
|
-
def load_config() -> GlobalConfig:
|
|
78
|
-
return load_global_config()
|
|
79
|
-
|
|
80
|
-
def save_config(config: GlobalConfig):
|
|
81
|
-
save_global_config(config)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/backend.yaml
RENAMED
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/clients.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/__init__.py
RENAMED
|
File without changes
|
{flowstash_cli-0.7.3 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|