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.
- flowstash/__init__.py +0 -0
- flowstash/commands/__init__.py +0 -0
- flowstash/commands/auth.py +112 -0
- flowstash/commands/build.py +127 -0
- flowstash/commands/deploy.py +143 -0
- flowstash/commands/project.py +555 -0
- flowstash/commands/run.py +65 -0
- flowstash/core/__init__.py +0 -0
- flowstash/core/api_client.py +62 -0
- flowstash/core/auth_server.py +45 -0
- flowstash/core/builder.py +40 -0
- flowstash/core/config.py +81 -0
- flowstash/core/docker_utils.py +33 -0
- flowstash/main.py +269 -0
- flowstash/templates/AGENTS.md +222 -0
- flowstash/templates/README.md +135 -0
- flowstash/templates/_.dockerignore +8 -0
- flowstash/templates/_.flowstash +4 -0
- flowstash/templates/_api_main.py +21 -0
- flowstash/templates/_config/[env]/(backend-asyncio)/backend.yaml +4 -0
- flowstash/templates/_config/[env]/(backend-dramatiq)/backend.yaml +8 -0
- flowstash/templates/_config/[env]/(backend-managed)/backend.yaml +6 -0
- flowstash/templates/_config/[env]/_backend.yaml +4 -0
- flowstash/templates/_config/shared/.env +1 -0
- flowstash/templates/_config/shared/backend.yaml +4 -0
- flowstash/templates/_config/shared/clients/demoClient.yaml +39 -0
- flowstash/templates/_config/shared/clients.yaml +3 -0
- flowstash/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +24 -0
- flowstash/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +34 -0
- flowstash/templates/_deployment/[env]/.env +3 -0
- flowstash/templates/_deployment/shared/.env +5 -0
- flowstash/templates/_deployment/shared/api.Dockerfile +18 -0
- flowstash/templates/_deployment/shared/worker.Dockerfile +18 -0
- flowstash/templates/_pyproject.toml +40 -0
- flowstash/templates/_src/_api/__init__.py +1 -0
- flowstash/templates/_src/_api/_routes/webhooks.py +25 -0
- flowstash/templates/_src/_shared/__init__.py +1 -0
- flowstash/templates/_src/_shared/clients/client.py +18 -0
- flowstash/templates/_src/_shared/models/models.py +1 -0
- flowstash/templates/_src/_shared/tasks/sharedTasks.py +10 -0
- flowstash/templates/_src/_worker/__init__.py +1 -0
- flowstash/templates/_src/_worker/tasks/tasks.py +15 -0
- flowstash/templates/_worker_main.py +25 -0
- flowstash/ui/__init__.py +0 -0
- flowstash_cli-0.1.0.dist-info/METADATA +19 -0
- flowstash_cli-0.1.0.dist-info/RECORD +48 -0
- flowstash_cli-0.1.0.dist-info/WHEEL +4 -0
- 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()
|