flowstash-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. flowstash/__init__.py +0 -0
  2. flowstash/commands/__init__.py +0 -0
  3. flowstash/commands/auth.py +112 -0
  4. flowstash/commands/build.py +127 -0
  5. flowstash/commands/deploy.py +143 -0
  6. flowstash/commands/project.py +555 -0
  7. flowstash/commands/run.py +65 -0
  8. flowstash/core/__init__.py +0 -0
  9. flowstash/core/api_client.py +62 -0
  10. flowstash/core/auth_server.py +45 -0
  11. flowstash/core/builder.py +40 -0
  12. flowstash/core/config.py +81 -0
  13. flowstash/core/docker_utils.py +33 -0
  14. flowstash/main.py +269 -0
  15. flowstash/templates/AGENTS.md +222 -0
  16. flowstash/templates/README.md +135 -0
  17. flowstash/templates/_.dockerignore +8 -0
  18. flowstash/templates/_.flowstash +4 -0
  19. flowstash/templates/_api_main.py +21 -0
  20. flowstash/templates/_config/[env]/(backend-asyncio)/backend.yaml +4 -0
  21. flowstash/templates/_config/[env]/(backend-dramatiq)/backend.yaml +8 -0
  22. flowstash/templates/_config/[env]/(backend-managed)/backend.yaml +6 -0
  23. flowstash/templates/_config/[env]/_backend.yaml +4 -0
  24. flowstash/templates/_config/shared/.env +1 -0
  25. flowstash/templates/_config/shared/backend.yaml +4 -0
  26. flowstash/templates/_config/shared/clients/demoClient.yaml +39 -0
  27. flowstash/templates/_config/shared/clients.yaml +3 -0
  28. flowstash/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +24 -0
  29. flowstash/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +34 -0
  30. flowstash/templates/_deployment/[env]/.env +3 -0
  31. flowstash/templates/_deployment/shared/.env +5 -0
  32. flowstash/templates/_deployment/shared/api.Dockerfile +18 -0
  33. flowstash/templates/_deployment/shared/worker.Dockerfile +18 -0
  34. flowstash/templates/_pyproject.toml +40 -0
  35. flowstash/templates/_src/_api/__init__.py +1 -0
  36. flowstash/templates/_src/_api/_routes/webhooks.py +25 -0
  37. flowstash/templates/_src/_shared/__init__.py +1 -0
  38. flowstash/templates/_src/_shared/clients/client.py +18 -0
  39. flowstash/templates/_src/_shared/models/models.py +1 -0
  40. flowstash/templates/_src/_shared/tasks/sharedTasks.py +10 -0
  41. flowstash/templates/_src/_worker/__init__.py +1 -0
  42. flowstash/templates/_src/_worker/tasks/tasks.py +15 -0
  43. flowstash/templates/_worker_main.py +25 -0
  44. flowstash/ui/__init__.py +0 -0
  45. flowstash_cli-0.1.0.dist-info/METADATA +19 -0
  46. flowstash_cli-0.1.0.dist-info/RECORD +48 -0
  47. flowstash_cli-0.1.0.dist-info/WHEEL +4 -0
  48. flowstash_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,555 @@
1
+ from typing import Optional, List, Dict
2
+ import typer
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from rich.prompt import Prompt
6
+ import asyncio
7
+ import questionary
8
+
9
+ from ..core.api_client import APIClient
10
+ from ..core.config import (
11
+ get_access_token,
12
+ load_project_config,
13
+ save_project_config,
14
+ ProjectConfig,
15
+ EnvironmentMode
16
+ )
17
+
18
+ app = typer.Typer()
19
+ console = Console()
20
+
21
+ import shutil
22
+
23
+ def find_project_root(start_path: Path = Path.cwd()) -> Optional[Path]:
24
+ """Find the nearest project root marked by .flowstash or pyproject.toml."""
25
+ for parent in [start_path] + list(start_path.parents):
26
+ if (parent / ".flowstash").exists() or (parent / "pyproject.toml").exists():
27
+ return parent
28
+ return None
29
+
30
+ def has_env_descendant(path: Path) -> bool:
31
+ """Check if a directory contains any [env] folder in its descendants."""
32
+ if not path.is_dir():
33
+ return False
34
+ if path.name == "[env]":
35
+ return True
36
+ for child in path.iterdir():
37
+ if child.name == "[env]":
38
+ return True
39
+ if child.is_dir() and has_env_descendant(child):
40
+ return True
41
+ return False
42
+
43
+ def process_node(
44
+ src: Path,
45
+ dest_parent: Path,
46
+ env_mode: Optional[EnvironmentMode],
47
+ is_fix: bool,
48
+ is_check: bool,
49
+ force: bool,
50
+ project_name: str,
51
+ root_path: Path,
52
+ errors: list,
53
+ target_env: Optional[str] = None
54
+ ):
55
+ name = src.name
56
+
57
+ # If target_env is set and we are NOT in an environment-specific context yet
58
+ if target_env and not env_mode:
59
+ # If this IS the [env] folder, we only care about target_env
60
+ if name == "[env]":
61
+ pass # Continue to [env] handling below
62
+ elif src.is_dir():
63
+ # If this is a directory, only proceed if it contains an [env] somewhere
64
+ if not has_env_descendant(src):
65
+ return
66
+ else:
67
+ # If this is a file at the root or outside [env], skip it for env-add
68
+ return
69
+
70
+ # 1. Condition check (...)
71
+ if name.startswith("(") and name.endswith(")"):
72
+ condition = name[1:-1]
73
+ if "-" in condition:
74
+ q, a = condition.split("-", 1)
75
+ if not env_mode:
76
+ return
77
+ if env_mode.options.get(q) != a:
78
+ return
79
+
80
+ # Condition MET. Process its children and place them in dest_parent
81
+ if src.is_dir():
82
+ for child in src.iterdir():
83
+ process_node(child, dest_parent, env_mode, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
84
+ return
85
+
86
+ # 2. Iterate environments [env]
87
+ if name == "[env]":
88
+ if src.is_dir():
89
+ project_config = load_project_config()
90
+ envs = project_config.environments if project_config else []
91
+
92
+ # If target_env is specified, only process that one
93
+ if target_env:
94
+ envs = [e for e in envs if e.name == target_env]
95
+
96
+ for em in envs:
97
+ new_dest = dest_parent / em.name
98
+ for child in src.iterdir():
99
+ process_node(child, new_dest, em, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
100
+ return
101
+
102
+ # 3. Handle `_` prefix
103
+ is_underscored = False
104
+ target_name = name
105
+ if target_name.startswith("_") and not target_name.startswith("__"):
106
+ is_underscored = True
107
+ target_name = target_name[1:]
108
+
109
+ target_path = dest_parent / target_name
110
+
111
+ # 4. Handle Directory vs File
112
+ if src.is_dir():
113
+ if not is_check:
114
+ should_create = True
115
+ if not target_path.exists():
116
+ if is_underscored and is_fix and not force:
117
+ should_create = False
118
+
119
+ if should_create:
120
+ target_path.mkdir(parents=True, exist_ok=True)
121
+
122
+ else:
123
+ if is_underscored and not target_path.exists():
124
+ errors.append(f"Missing mandatory folder: [bold]{target_path.relative_to(root_path)}[/bold]")
125
+
126
+ # Recurse children
127
+ for child in src.iterdir():
128
+ process_node(child, target_path, env_mode, is_fix, is_check, force, project_name, root_path, errors, target_env=target_env)
129
+
130
+ else:
131
+ # File
132
+ if not is_check:
133
+ should_create = True
134
+ if target_path.exists() and not force:
135
+ should_create = False
136
+
137
+ if not target_path.exists():
138
+ if is_underscored and is_fix and not force:
139
+ should_create = False
140
+
141
+ if should_create:
142
+ try:
143
+ content = src.read_text()
144
+ content = content.replace("{project_name}", project_name)
145
+ project_name_slug = project_name.lower().replace("_", "-").strip("-")
146
+ if not project_name_slug:
147
+ project_name_slug = "flowstash-project"
148
+ content = content.replace("{project_name_slug}", project_name_slug)
149
+ if env_mode:
150
+ content = content.replace("{env}", env_mode.name)
151
+ content = content.replace("[env]", env_mode.name)
152
+
153
+ target_path.parent.mkdir(parents=True, exist_ok=True)
154
+ target_path.write_text(content)
155
+ status = "Updated" if target_path.exists() and force else "Created"
156
+
157
+ # Compute relative path or just use absolute if not under root
158
+ try:
159
+ rel_path = target_path.relative_to(root_path)
160
+ except ValueError:
161
+ rel_path = target_path
162
+ console.print(f"{status}: [bold]{rel_path}[/bold]")
163
+ except Exception as e:
164
+ console.print(f"[red]Failed to process {src.name}: {e}[/red]")
165
+ else:
166
+ if is_underscored and not target_path.exists():
167
+ try:
168
+ rel_path = target_path.relative_to(root_path)
169
+ except ValueError:
170
+ rel_path = target_path
171
+ errors.append(f"Missing mandatory file: [bold]{rel_path}[/bold]")
172
+
173
+
174
+
175
+ @app.command()
176
+ def check():
177
+ """Inspect the current project against the spec and produce a report."""
178
+ root = find_project_root()
179
+ if not root:
180
+ console.print("[red]Not in a flowstash project (no .flowstash or pyproject.toml found).[/red]")
181
+ raise typer.Exit(code=1)
182
+
183
+ console.print(f"Project root: [bold]{root}[/bold]")
184
+
185
+ project_config = load_project_config()
186
+ errors = []
187
+
188
+ if (root / ".flowstash").exists():
189
+ if project_config is None:
190
+ errors.append("[bold].flowstash[/bold] exists but is invalid YAML or has a wrong schema.")
191
+ else:
192
+ if not project_config.project_name:
193
+ errors.append("[bold].flowstash[/bold] is missing [bold]project_name[/bold].")
194
+
195
+ project_name = project_config.project_name if project_config else root.name
196
+
197
+ templates_dir = Path(__file__).parent.parent / "templates"
198
+ for child in templates_dir.iterdir():
199
+ process_node(child, root, None, is_fix=False, is_check=True, force=False, project_name=project_name, root_path=root, errors=errors)
200
+
201
+ if not errors:
202
+ console.print("[green]Project structure is valid![/green]")
203
+ else:
204
+ console.print("[red]Structure issues found:[/red]")
205
+ for err in errors:
206
+ console.print(f" - {err}")
207
+ console.print("\n[yellow]Run 'flowstash init --fix' to attempt repair.[/yellow]")
208
+
209
+
210
+ def init_env(env_name: str) -> EnvironmentMode:
211
+ answers = {}
212
+
213
+ # Backend
214
+ backend_choices = [
215
+ questionary.Choice("Asyncio - only for local development... not recomended for production", "asyncio"),
216
+ questionary.Choice("Dramatiq task queue - ideal for dev envirment / self hosting", "dramatiq")
217
+ ]
218
+ if env_name != "local":
219
+ backend_choices.append(questionary.Choice("Cloud managed backend, scales from 0->inf+ ... ideal for hasle free", "managed"))
220
+
221
+ backend = questionary.select(
222
+ "Backend type:\nPick one of these backend types:",
223
+ choices=backend_choices
224
+ ).ask()
225
+ if backend:
226
+ answers["backend"] = backend
227
+
228
+ # Observability
229
+ obs_choices = [
230
+ questionary.Choice("None or custom observability", "unset"),
231
+ questionary.Choice("Log events to log file", "logfile"),
232
+ #questionary.Choice("OpenTelemetry", "otel"),
233
+ questionary.Choice("Use flowstash managed monitoring", "managed")
234
+ ]
235
+ obs = questionary.select(
236
+ "Observability:",
237
+ choices=obs_choices
238
+ ).ask()
239
+ if obs:
240
+ answers["observability"] = obs
241
+
242
+ is_managed = (answers.get("backend") == "managed") or (answers.get("observability") == "managed")
243
+ return EnvironmentMode(name=env_name, managed=is_managed, options=answers)
244
+
245
+ def init_vscode_launch(env_name: str, force: bool = False):
246
+ """Initialize VS Code launch settings for a specific environment."""
247
+ root = find_project_root()
248
+ if not root:
249
+ console.print("[red]Not in a flowstash project. Run 'flowstash init' first.[/red]")
250
+ raise typer.Exit(code=1)
251
+
252
+ project_config = load_project_config()
253
+ if not project_config:
254
+ console.print("[red]Invalid project configuration.[/red]")
255
+ raise typer.Exit(code=1)
256
+
257
+ # Find the environment
258
+ env_to_init = next((e for e in project_config.environments if e.name == env_name), None)
259
+ if not env_to_init:
260
+ console.print(f"[red]Environment '{env_name}' not found.[/red]")
261
+ raise typer.Exit(code=1)
262
+
263
+ if env_to_init.managed:
264
+ console.print(f"[red]Cannot initialize VS Code launch for managed environment '{env_name}'.[/red]")
265
+ raise typer.Exit(code=1)
266
+
267
+ vscode_dir = root / ".vscode"
268
+ vscode_dir.mkdir(parents=True, exist_ok=True)
269
+ launch_json_path = vscode_dir / "launch.json"
270
+
271
+ import json
272
+
273
+ launch_data = {"version": "0.2.0", "configurations": []}
274
+ if launch_json_path.exists():
275
+ try:
276
+ with open(launch_json_path, "r") as f:
277
+ launch_data = json.load(f)
278
+ except json.JSONDecodeError:
279
+ # Maybe there are comments or it's malformed. We'll ask to overwrite.
280
+ if not force:
281
+ console.print(f"[yellow]Warning: {launch_json_path} contains invalid JSON or comments. Cannot parse it automatically. Use --force to overwrite it entirely.[/yellow]")
282
+ raise typer.Exit(code=1)
283
+ launch_data = {"version": "0.2.0", "configurations": []}
284
+
285
+ api_config_name = f"Launch {env_name} API"
286
+ worker_config_name = f"Launch {env_name} Worker"
287
+
288
+ existing_configs = {cfg.get("name") for cfg in launch_data.get("configurations", [])}
289
+
290
+ added = False
291
+
292
+ if api_config_name not in existing_configs or force:
293
+ # Remove old if force
294
+ launch_data["configurations"] = [c for c in launch_data.setdefault("configurations", []) if c.get("name") != api_config_name]
295
+
296
+ launch_data["configurations"].append({
297
+ "name": api_config_name,
298
+ "type": "debugpy",
299
+ "request": "launch",
300
+ "program": "${workspaceFolder}/api_main.py",
301
+ "console": "integratedTerminal",
302
+ "env": {"ENVIRONMENT": env_name}
303
+ })
304
+ added = True
305
+
306
+ if worker_config_name not in existing_configs or force:
307
+ # Remove old if force
308
+ launch_data["configurations"] = [c for c in launch_data.setdefault("configurations", []) if c.get("name") != worker_config_name]
309
+
310
+ launch_data["configurations"].append({
311
+ "name": worker_config_name,
312
+ "type": "debugpy",
313
+ "request": "launch",
314
+ "program": "${workspaceFolder}/worker_main.py",
315
+ "console": "integratedTerminal",
316
+ "env": {"ENVIRONMENT": env_name}
317
+ })
318
+ added = True
319
+
320
+ if added:
321
+ with open(launch_json_path, "w") as f:
322
+ json.dump(launch_data, f, indent=4)
323
+ console.print(f"[green]Added VS Code launch configurations for '{env_name}' to {launch_json_path}[/green]")
324
+ else:
325
+ console.print(f"VS Code launch configurations already exist for '{env_name}'.")
326
+
327
+
328
+
329
+ def add_environment(project_config: ProjectConfig, env_name: Optional[str] = None):
330
+ if not env_name:
331
+ existing_envs = {e.name for e in project_config.environments}
332
+ env_choices = [
333
+ questionary.Choice("Local (local)", "local"),
334
+ questionary.Choice("Development (dev)", "dev"),
335
+ questionary.Choice("Production (prod)", "prod"),
336
+ questionary.Choice("Custom", "custom")
337
+ ]
338
+ # Filter out existing (except custom)
339
+ env_choices = [c for c in env_choices if c.value == "custom" or c.value not in existing_envs]
340
+
341
+ env_sel = questionary.select(
342
+ "Environment name:",
343
+ choices=env_choices
344
+ ).ask()
345
+
346
+ if not env_sel:
347
+ return
348
+
349
+ if env_sel == "custom":
350
+ env_name = Prompt.ask("Enter custom environment name")
351
+ else:
352
+ env_name = env_sel
353
+
354
+ if not env_name or any(e.name == env_name for e in project_config.environments):
355
+ console.print("[red]Environment already exists or invalid name![/red]")
356
+ return None
357
+
358
+ mode = init_env(env_name)
359
+ project_config.environments.append(mode)
360
+ save_project_config(project_config)
361
+ return mode
362
+
363
+
364
+ @app.command("add")
365
+ def env_add(
366
+ env_name: Optional[str] = typer.Argument(None, help="Environment name to add"),
367
+ force: bool = typer.Option(False, "--force", help="Force overwrite existing files")
368
+ ):
369
+ """Add a new environment to the project."""
370
+ root = find_project_root()
371
+ if not root:
372
+ console.print("[red]Not in a flowstash project. Run 'flowstash init' first.[/red]")
373
+ raise typer.Exit(code=1)
374
+
375
+ project_config = load_project_config()
376
+ if not project_config:
377
+ console.print("[red]Invalid project configuration.[/red]")
378
+ raise typer.Exit(code=1)
379
+
380
+ added_mode = add_environment(project_config, env_name=env_name)
381
+ if not added_mode:
382
+ return
383
+
384
+ # Reload config to get the newly added environment
385
+ project_config = load_project_config()
386
+
387
+ # We now know the actual environment name picked
388
+ env_name = added_mode.name
389
+
390
+ # Check if we should ask for linkage
391
+ is_managed = any(e.managed for e in project_config.environments)
392
+ if not project_config.project_id and is_managed:
393
+ if Prompt.ask("Link to managed project?", choices=["y", "n"], default="y") == "y":
394
+ _link_project(project_config)
395
+
396
+ # Process templates for the new environment
397
+ templates_dir = Path(__file__).parent.parent / "templates"
398
+ errors = []
399
+ project_name = project_config.project_name or root.name
400
+
401
+ for child in templates_dir.iterdir():
402
+ process_node(child, root, None, is_fix=True, is_check=False, force=force, project_name=project_name, root_path=root, errors=errors, target_env=env_name)
403
+
404
+ console.print(f"[green]Environment configuration updated![/green]")
405
+
406
+ if not added_mode.managed:
407
+ if Prompt.ask("Would you like to initialize VS Code launch configurations for this environment?", choices=["y", "n"], default="y") == "y":
408
+ init_vscode_launch(env_name=env_name, force=force)
409
+
410
+
411
+ @app.command("delete")
412
+ def env_delete(
413
+ env_name: str = typer.Argument(..., help="Name of the environment to delete")
414
+ ):
415
+ """
416
+ [bold red]Delete an environment[/bold red] from your project.
417
+
418
+ This will remove the environment from .flowstash and optionally delete its configuration folder.
419
+ """
420
+ root = find_project_root()
421
+ if not root:
422
+ console.print("[red]Not in a flowstash project. Run 'flowstash init' first.[/red]")
423
+ raise typer.Exit(code=1)
424
+
425
+ project_config = load_project_config()
426
+ if not project_config:
427
+ console.print("[red]Invalid project configuration.[/red]")
428
+ raise typer.Exit(code=1)
429
+
430
+ # Find the environment
431
+ env_to_del = next((e for e in project_config.environments if e.name == env_name), None)
432
+ if not env_to_del:
433
+ console.print(f"[red]Environment '{env_name}' not found.[/red]")
434
+ console.print(f"[yellow]Available environments: {', '.join(e.name for e in project_config.environments)}[/yellow]")
435
+ raise typer.Exit(code=1)
436
+
437
+ from rich.prompt import Confirm
438
+ if not Confirm.ask(f"Are you sure you want to delete environment [bold red]{env_name}[/bold red]?"):
439
+ console.print("Cancelled.")
440
+ return
441
+
442
+ # Remove from config
443
+ project_config.environments = [e for e in project_config.environments if e.name != env_name]
444
+ save_project_config(project_config)
445
+
446
+ # Optionally delete the folder
447
+ env_folder = root / env_name
448
+ if env_folder.exists() and env_folder.is_dir():
449
+ if Confirm.ask(f"Do you also want to delete the configuration folder [bold]{env_name}/[/bold]?"):
450
+ import shutil
451
+ shutil.rmtree(env_folder)
452
+ console.print(f"Deleted folder: [bold]{env_name}/[/bold]")
453
+
454
+ console.print(f"[green]Environment '{env_name}' deleted successfully![/green]")
455
+
456
+
457
+ @app.command()
458
+ def init(
459
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Project name"),
460
+ fix: bool = typer.Option(False, "--fix", help="Repair missing components"),
461
+ force: bool = typer.Option(False, "--force", help="Force overwrite existing files")
462
+ ):
463
+ """Initialize or repair a flowstash project."""
464
+ root = Path.cwd()
465
+ project_config = load_project_config()
466
+
467
+ if project_config and not fix:
468
+ if not Prompt.ask("Project already initialized. Re-initialize?", choices=["y", "n"], default="n") == "y":
469
+ return
470
+
471
+ if not name:
472
+ name = project_config.project_name if project_config else root.name
473
+ name = Prompt.ask("Project name", default=name)
474
+
475
+ if not project_config:
476
+ project_config = ProjectConfig(project_name=name)
477
+ save_project_config(project_config)
478
+ console.print(f"[green]Created .flowstash with project_name: {name}[/green]")
479
+ else:
480
+ project_config.project_name = name
481
+ save_project_config(project_config)
482
+
483
+ # Environments Setup
484
+ if not project_config.environments:
485
+ # Ask to configure at least the first environment
486
+ setup_first = Prompt.ask("Setup first eniroment? y/n?", choices=["y", "n"], default="y")
487
+ if setup_first == "y":
488
+ added_mode = add_environment(project_config)
489
+ if added_mode and not added_mode.managed:
490
+ if Prompt.ask(f"Would you like to initialize VS Code launch configurations for {added_mode.name}?", choices=["y", "n"], default="y") == "y":
491
+ init_vscode_launch(env_name=added_mode.name, force=force)
492
+ else:
493
+ if not fix:
494
+ while Prompt.ask("Add another enviroment? y/n?", choices=["y", "n"], default="n") == "y":
495
+ added_mode = add_environment(project_config)
496
+ if added_mode and not added_mode.managed:
497
+ if Prompt.ask(f"Would you like to initialize VS Code launch configurations for {added_mode.name}?", choices=["y", "n"], default="y") == "y":
498
+ init_vscode_launch(env_name=added_mode.name, force=force)
499
+
500
+
501
+ templates_dir = Path(__file__).parent.parent / "templates"
502
+ errors = []
503
+
504
+ for child in templates_dir.iterdir():
505
+ process_node(child, root, None, is_fix=fix, is_check=False, force=force, project_name=name, root_path=root, errors=errors)
506
+
507
+ # Prompt for project linkage if not linked AND handled by managed services
508
+ is_managed = any(e.managed for e in project_config.environments)
509
+ if not project_config.project_id and is_managed:
510
+ if Prompt.ask("Link to managed project?", choices=["y", "n"], default="y") == "y":
511
+ _link_project(project_config)
512
+
513
+ console.print("[green]Initialization/Repair complete![/green]")
514
+
515
+ def _link_project(config: ProjectConfig):
516
+ token = get_access_token()
517
+ if not token:
518
+ console.print("[yellow]Not logged in. Use 'flowstash login' to link to a managed project.[/yellow]")
519
+ return
520
+
521
+ api = APIClient()
522
+ try:
523
+ projects_data = asyncio.run(api.get("/v1/projects"))
524
+ projects = projects_data.get("projects", [])
525
+ except Exception as e:
526
+ console.print(f"[red]Failed to fetch projects: {e}[/red]")
527
+ return
528
+
529
+ choices = ["Create new project"] + [f"{p['name']} ({p['project_id']})" for p in projects]
530
+ choice = questionary.select("Select a managed project", choices=choices).ask()
531
+
532
+ if not choice: return
533
+
534
+ if choice == "Create new project":
535
+ name = Prompt.ask("Enter name for new managed project", default=config.project_name)
536
+ try:
537
+ project = asyncio.run(api.post("/v1/projects", json={"name": name}))
538
+ project_id = project["project_id"]
539
+ except Exception as e:
540
+ console.print(f"[red]Failed to create project: {e}[/red]")
541
+ return
542
+ else:
543
+ project_id = choice.split("(")[-1].rstrip(")")
544
+
545
+ # Fetch tenant
546
+ try:
547
+ user_info = asyncio.run(api.get("/v1/auth/me"))
548
+ config.tenant_id = user_info["tenant_id"]
549
+ config.user_email = user_info.get("email")
550
+ except:
551
+ config.tenant_id = "default"
552
+
553
+ config.project_id = project_id
554
+ save_project_config(config)
555
+ console.print(f"[green]Linked to project_id: {project_id}[/green]")
@@ -0,0 +1,65 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from rich.console import Console
4
+ import subprocess
5
+ import os
6
+ from .project import find_project_root
7
+ from ..core.docker_utils import check_docker_binary, check_docker_compose_binary, check_docker_daemon, get_docker_compose_cmd
8
+
9
+ app = typer.Typer()
10
+ console = Console()
11
+
12
+ @app.command()
13
+ def run(
14
+ env: str = typer.Argument(..., help="Environment to run"),
15
+ build: bool = typer.Option(True, "--build/--no-build", help="Build images before starting"),
16
+ detach: bool = typer.Option(False, "--detach", "-d", help="Run in background"),
17
+ logs: bool = typer.Option(False, "--logs", help="Follow logs (useful with --detach)")
18
+ ):
19
+ """Run the project locally via Docker Compose."""
20
+ root = find_project_root()
21
+ if not root:
22
+ console.print("[red]Not in a flowstash project.[/red]")
23
+ raise typer.Exit(code=1)
24
+
25
+ # Docker prerequisites
26
+ if not check_docker_binary():
27
+ console.print("[red]Docker binary not found. Please install Docker.[/red]")
28
+ raise typer.Exit(code=1)
29
+
30
+ if not check_docker_compose_binary():
31
+ console.print("[red]Docker Compose not found. Please install Docker Compose.[/red]")
32
+ raise typer.Exit(code=1)
33
+
34
+ daemon_running, daemon_err = check_docker_daemon()
35
+ if not daemon_running:
36
+ console.print(f"[red]Docker daemon not reachable: {daemon_err}[/red]")
37
+ raise typer.Exit(code=1)
38
+
39
+ compose_file = root / "deployment" / env / "docker-compose.yaml"
40
+ if not compose_file.exists():
41
+ console.print(f"[red]No docker-compose.yaml found for env '{env}' at {compose_file}[/red]")
42
+ console.print("[yellow]Try running 'flowstash init --fix' to create it.[/yellow]")
43
+ raise typer.Exit(code=1)
44
+
45
+ cmd = get_docker_compose_cmd() + ["-f", str(compose_file), "up"]
46
+ if build:
47
+ cmd.append("--build")
48
+ if detach:
49
+ cmd.append("-d")
50
+
51
+ console.print(f"Running flowstash in [bold]{env}[/bold] mode...")
52
+
53
+ try:
54
+ # We want to stream output directly to terminal
55
+ subprocess.run(cmd, check=True, cwd=root)
56
+
57
+ if detach and logs:
58
+ log_cmd = get_docker_compose_cmd() + ["-f", str(compose_file), "logs", "-f"]
59
+ subprocess.run(log_cmd, check=True, cwd=root)
60
+
61
+ except subprocess.CalledProcessError:
62
+ console.print("[red]Docker Compose command failed.[/red]")
63
+ raise typer.Exit(code=1)
64
+ except KeyboardInterrupt:
65
+ console.print("\n[yellow]Stopping...[/yellow]")
File without changes
@@ -0,0 +1,62 @@
1
+ import httpx
2
+ from typing import Optional, Dict, Any
3
+ from .config import load_global_config, get_access_token
4
+
5
+ class APIClient:
6
+ def __init__(self):
7
+ self.config = load_global_config()
8
+ self.base_url = self.config.api_url.rstrip("/")
9
+
10
+ def _get_headers(self) -> Dict[str, str]:
11
+ headers = {}
12
+ token = get_access_token()
13
+ if token:
14
+ headers["Authorization"] = f"Bearer {token}"
15
+ return headers
16
+
17
+ async def get(self, path: str, params: Optional[Dict[str, Any]] = None):
18
+ async with httpx.AsyncClient(timeout=30.0) as client:
19
+ response = await client.get(
20
+ f"{self.base_url}{path}",
21
+ params=params,
22
+ headers=self._get_headers()
23
+ )
24
+ response.raise_for_status()
25
+ return response.json()
26
+
27
+ async def post(self, path: str, json: Optional[Dict[str, Any]] = None):
28
+ async with httpx.AsyncClient(timeout=30.0) as client:
29
+ response = await client.post(
30
+ f"{self.base_url}{path}",
31
+ json=json,
32
+ headers=self._get_headers()
33
+ )
34
+ response.raise_for_status()
35
+ return response.json()
36
+
37
+ async def put_binary(self, url: str, data: bytes, headers: Optional[Dict[str, str]] = None):
38
+ async with httpx.AsyncClient(timeout=60.0) as client:
39
+ response = await client.put(
40
+ url,
41
+ content=data,
42
+ headers=headers or {}
43
+ )
44
+ response.raise_for_status()
45
+ return response
46
+
47
+ # Generic request method if needed
48
+ async def request(self, method: str, path: str, **kwargs):
49
+ async with httpx.AsyncClient(timeout=30.0) as client:
50
+ url = f"{self.base_url}{path}"
51
+ headers = self._get_headers()
52
+ if "headers" in kwargs:
53
+ headers.update(kwargs.pop("headers"))
54
+
55
+ response = await client.request(
56
+ method,
57
+ url,
58
+ headers=headers,
59
+ **kwargs
60
+ )
61
+ response.raise_for_status()
62
+ return response.json()