flowstash-cli 0.7.2__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.
Files changed (50) hide show
  1. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/PKG-INFO +2 -2
  2. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/pyproject.toml +2 -2
  3. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/apikey.py +24 -10
  4. flowstash_cli-0.8.0/src/flowstash/cli/commands/auth.py +286 -0
  5. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/build.py +19 -6
  6. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/deploy.py +16 -6
  7. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/project.py +6 -4
  8. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/api_client.py +6 -3
  9. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/auth_server.py +16 -0
  10. flowstash_cli-0.8.0/src/flowstash/cli/core/config.py +200 -0
  11. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/main.py +32 -8
  12. flowstash_cli-0.7.2/src/flowstash/cli/commands/auth.py +0 -151
  13. flowstash_cli-0.7.2/src/flowstash/cli/core/config.py +0 -81
  14. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/__init__.py +0 -0
  15. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/__init__.py +0 -0
  16. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/client.py +0 -0
  17. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/run.py +0 -0
  18. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/commands/webhook.py +0 -0
  19. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/__init__.py +0 -0
  20. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/builder.py +0 -0
  21. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/docker_utils.py +0 -0
  22. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/core/patcher.py +0 -0
  23. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/AGENTS.md +0 -0
  24. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/README.md +0 -0
  25. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_.dockerignore +0 -0
  26. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_.flowstash +0 -0
  27. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_api_main.py +0 -0
  28. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-asyncio)/backend.yaml +0 -0
  29. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-dramatiq)/backend.yaml +0 -0
  30. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(backend-managed)/backend.yaml +0 -0
  31. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(observability-logfile)/observability.yaml +0 -0
  32. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/[env]/(observability-managed)/observability.yaml +0 -0
  33. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/backend.yaml +0 -0
  34. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/clients/demoClient.yaml +0 -0
  35. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_config/shared/clients.yaml +0 -0
  36. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +0 -0
  37. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +0 -0
  38. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/shared/api.Dockerfile +0 -0
  39. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_deployment/shared/worker.Dockerfile +0 -0
  40. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_pyproject.toml +0 -0
  41. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_api/__init__.py +0 -0
  42. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_api/_routes/webhooks.py +0 -0
  43. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/__init__.py +0 -0
  44. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/clients/client.py +0 -0
  45. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/models/models.py +0 -0
  46. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_shared/tasks/sharedTasks.py +0 -0
  47. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/__init__.py +0 -0
  48. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py +0 -0
  49. {flowstash_cli-0.7.2 → flowstash_cli-0.8.0}/src/flowstash/cli/templates/_worker_main.py +0 -0
  50. {flowstash_cli-0.7.2 → 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.7.2
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.7.2,<0.8.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.7.2"
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.7.2,<0.8.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 = get_access_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 = get_access_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 = get_access_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
- api = APIClient()
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
- api = APIClient()
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(env: str, artifact_id: Optional[str] = None):
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
- api = APIClient()
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 = get_access_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
- except:
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(self, path: str, json: Optional[Dict[str, Any]] = None, timeout: float = 30.0):
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)