flowstash-cli 0.8.2__tar.gz → 0.9.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 (49) hide show
  1. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/PKG-INFO +2 -2
  2. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/pyproject.toml +2 -2
  3. flowstash_cli-0.9.0/src/flowstash/cli/commands/deploy.py +479 -0
  4. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/main.py +6 -74
  5. flowstash_cli-0.8.2/src/flowstash/cli/commands/deploy.py +0 -178
  6. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/__init__.py +0 -0
  7. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/__init__.py +0 -0
  8. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/apikey.py +0 -0
  9. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/auth.py +0 -0
  10. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/build.py +0 -0
  11. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/client.py +0 -0
  12. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/project.py +0 -0
  13. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/run.py +0 -0
  14. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/commands/webhook.py +0 -0
  15. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/__init__.py +0 -0
  16. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/api_client.py +0 -0
  17. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/auth_server.py +0 -0
  18. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/builder.py +0 -0
  19. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/config.py +0 -0
  20. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/docker_utils.py +0 -0
  21. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/core/patcher.py +0 -0
  22. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/AGENTS.md +0 -0
  23. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/README.md +0 -0
  24. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_.dockerignore +0 -0
  25. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_.flowstash +0 -0
  26. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_api_main.py +0 -0
  27. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/[env]/(backend-asyncio)/backend.yaml +0 -0
  28. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/[env]/(backend-dramatiq)/backend.yaml +0 -0
  29. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/[env]/(backend-managed)/backend.yaml +0 -0
  30. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/[env]/(observability-logfile)/observability.yaml +0 -0
  31. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/[env]/(observability-managed)/observability.yaml +0 -0
  32. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/shared/backend.yaml +0 -0
  33. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/shared/clients/demoClient.yaml +0 -0
  34. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_config/shared/clients.yaml +0 -0
  35. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +0 -0
  36. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +0 -0
  37. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_deployment/shared/api.Dockerfile +0 -0
  38. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_deployment/shared/worker.Dockerfile +0 -0
  39. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_pyproject.toml +0 -0
  40. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_api/__init__.py +0 -0
  41. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_api/_routes/webhooks.py +0 -0
  42. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_shared/__init__.py +0 -0
  43. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_shared/clients/client.py +0 -0
  44. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_shared/models/models.py +0 -0
  45. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_shared/tasks/sharedTasks.py +0 -0
  46. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_worker/__init__.py +0 -0
  47. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py +0 -0
  48. {flowstash_cli-0.8.2 → flowstash_cli-0.9.0}/src/flowstash/cli/templates/_worker_main.py +0 -0
  49. {flowstash_cli-0.8.2 → flowstash_cli-0.9.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.8.2
3
+ Version: 0.9.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.8.2,<0.9.0)
12
+ Requires-Dist: flowstash-runtime (>=0.9.0,<0.10.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.8.2"
3
+ version = "0.9.0"
4
4
  description = "CLI for the flowstash Managed Platform"
5
5
  authors = [{name = "juraj.bezdek@gmail.com", email = "juraj.bezdek@gmail.com"}]
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
- "flowstash-runtime>=0.8.2,<0.9.0",
8
+ "flowstash-runtime>=0.9.0,<0.10.0",
9
9
  "typer[all]>=0.12.0",
10
10
  "httpx>=0.27.0",
11
11
  "pyyaml>=6.0.1",
@@ -0,0 +1,479 @@
1
+ from typing import Optional
2
+ import asyncio
3
+ import typer
4
+ import click
5
+ from typer.core import TyperGroup
6
+ from rich.console import Console
7
+ from rich.progress import Progress, SpinnerColumn, TextColumn
8
+ from rich.prompt import Confirm
9
+ import questionary
10
+ from ..core.api_client import APIClient
11
+ from ..core.config import load_project_config, resolve_credentials
12
+ from .build import run_build_flow
13
+ import flowstash.runtime
14
+
15
+
16
+ class DefaultCommandGroup(TyperGroup):
17
+ """Typer group that falls back to a default subcommand.
18
+
19
+ Keeps ``flowstash deploy``, ``flowstash deploy <env>`` and
20
+ ``flowstash deploy --artifact X`` working (they route to ``run``) while
21
+ still allowing real subcommands such as ``configure``.
22
+ """
23
+
24
+ DEFAULT_CMD = "run"
25
+
26
+ def parse_args(self, ctx, args):
27
+ if not args:
28
+ args = [self.DEFAULT_CMD]
29
+ elif args[0] not in self.commands and args[0] != "--help":
30
+ args = [self.DEFAULT_CMD, *args]
31
+ return super().parse_args(ctx, args)
32
+
33
+
34
+ app = typer.Typer(cls=DefaultCommandGroup)
35
+ console = Console()
36
+
37
+ # Status labels shown to the user while polling
38
+ _STATUS_LABELS = {
39
+ "QUEUED": "Queued, waiting for deployment to start...",
40
+ "VALIDATING": "Validating container images...",
41
+ "DEPLOYING": "Deploying services...",
42
+ "HEALTH_CHECK": "Health-checking API and Worker...",
43
+ "SYNCING_SCHEDULES": "Waiting for deployment verification...",
44
+ "PROFILE_UPDATE": "Applying profile update...",
45
+ "DEPLOYED": "Deployed successfully ✓",
46
+ "FAILED": "Deployment failed.",
47
+ }
48
+
49
+ _TERMINAL_STATUSES = {"DEPLOYED", "FAILED"}
50
+
51
+
52
+ async def _poll_deploy_status(api: APIClient, deploy_id: str, progress, task):
53
+ """Poll deploy status until terminal. Returns the final status payload.
54
+
55
+ On FAILED, prints the backend error and raises typer.Exit(1).
56
+ """
57
+ last_status = None
58
+ while True:
59
+ status_data = await api.get(f"/v1/deploy/{deploy_id}/status")
60
+ current_status = status_data["status"]
61
+
62
+ if current_status != last_status:
63
+ label = _STATUS_LABELS.get(current_status, f"Status: {current_status}")
64
+ progress.update(task, description=label)
65
+ last_status = current_status
66
+
67
+ if current_status == "DEPLOYED":
68
+ return status_data
69
+ elif current_status == "FAILED":
70
+ error = status_data.get("error_message", "Unknown error")
71
+ console.print(f"[red]Deployment failed: {error}[/red]")
72
+ raise typer.Exit(code=1)
73
+
74
+ await asyncio.sleep(5)
75
+
76
+
77
+ async def run_deploy_flow(
78
+ env: str, artifact_id: Optional[str] = None, user: Optional[str] = None
79
+ ):
80
+ project_config = load_project_config()
81
+ if not project_config:
82
+ console.print(
83
+ "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
84
+ )
85
+ raise typer.Exit(code=1)
86
+
87
+ project_id = project_config.project_id
88
+ if not project_id:
89
+ console.print("[red]project_id not found in .flowstash[/red]")
90
+ raise typer.Exit(code=1)
91
+
92
+ token = resolve_credentials(user=user)
93
+ if not token:
94
+ console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
95
+ raise typer.Exit(code=1)
96
+
97
+ api = APIClient(token=token)
98
+
99
+ # 1. If artifact_id is not provided, run build first
100
+ if not artifact_id:
101
+ console.print("No artifact ID provided. Building first...")
102
+ build_result = await run_build_flow(user=user)
103
+ artifact_id = build_result["artifact_id"]
104
+ console.print(f"Build finished. Deploying artifact: [bold]{artifact_id}[/bold]")
105
+
106
+ with Progress(
107
+ SpinnerColumn(),
108
+ TextColumn("[progress.description]{task.description}"),
109
+ transient=True,
110
+ ) as progress:
111
+ # 2. Trigger deployment
112
+ task = progress.add_task(description="Triggering deployment...", total=None)
113
+ deploy_data = await api.post(
114
+ "/v1/deploy",
115
+ json={
116
+ "project_id": project_id,
117
+ "artifact_id": artifact_id,
118
+ "env_vars": {"ENVIRONMENT": env},
119
+ "flowstash_runtime_version": flowstash.runtime.__version__,
120
+ },
121
+ )
122
+ deploy_id = deploy_data["deploy_id"]
123
+ progress.update(task, description=f"Deployment triggered (ID: {deploy_id}).")
124
+
125
+ # 3. Poll status until terminal
126
+ status_data = await _poll_deploy_status(api, deploy_id, progress, task)
127
+
128
+ return status_data
129
+
130
+
131
+ def _resolve_project_and_token(user: Optional[str]):
132
+ """Load .flowstash project_id and resolve an access token, or exit."""
133
+ project_config = load_project_config()
134
+ if not project_config:
135
+ console.print(
136
+ "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
137
+ )
138
+ raise typer.Exit(code=1)
139
+
140
+ project_id = project_config.project_id
141
+ if not project_id:
142
+ console.print(
143
+ "[red]project_id not found in .flowstash. Use 'flowstash init' or link to a project.[/red]"
144
+ )
145
+ raise typer.Exit(code=1)
146
+
147
+ token = resolve_credentials(user=user)
148
+ if not token:
149
+ console.print("[red]Not logged in. Run 'flowstash login' first.[/red]")
150
+ raise typer.Exit(code=1)
151
+
152
+ return project_config, project_id, token
153
+
154
+
155
+ def _spec_summary(spec: dict) -> str:
156
+ """Render a compact one-line summary of a deployment-profile spec."""
157
+ if not isinstance(spec, dict):
158
+ return ""
159
+ parts = []
160
+ worker = spec.get("worker_service") or {}
161
+ if worker:
162
+ parts.append(
163
+ f"worker cpu={worker.get('cpu')} mem={worker.get('memory')} "
164
+ f"max={worker.get('max_instances')}"
165
+ )
166
+ api_svc = spec.get("api_service") or {}
167
+ if api_svc:
168
+ parts.append(f"api cpu={api_svc.get('cpu')} mem={api_svc.get('memory')}")
169
+ return " / ".join(parts)
170
+
171
+
172
+ @app.command("run")
173
+ def deploy_run(
174
+ ctx: typer.Context,
175
+ env: str = typer.Argument("prod", help="Environment to deploy to (default: prod)"),
176
+ artifact: Optional[str] = typer.Option(
177
+ None, "--artifact", "-a", help="Artifact ID to deploy"
178
+ ),
179
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
180
+ user: Optional[str] = typer.Option(
181
+ None, "--user", "-u", help="Account to use (default: project-linked or current)"
182
+ ),
183
+ ):
184
+ """
185
+ [bold cyan]Deploy[/bold cyan] your project to the flowstash Managed Platform.
186
+
187
+ Defaults to the 'prod' environment. If 'prod' is missing, it will prompt you to set it up.
188
+
189
+ [yellow]Note:[/yellow] To deploy to a specific environment, use: [bold]flowstash deploy <env_name>[/bold]
190
+ """
191
+ from .project import add_environment, _link_project
192
+
193
+ if ctx.get_parameter_source("env") == click.core.ParameterSource.DEFAULT:
194
+ console.print(
195
+ f"[dim]Env argument not specified... using [bold]{env}[/bold] as default[/dim]"
196
+ )
197
+
198
+ project_config = load_project_config()
199
+ if not project_config:
200
+ console.print(
201
+ "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
202
+ )
203
+ raise typer.Exit(code=1)
204
+
205
+ # Find the requested environment, offering to scaffold 'prod' if missing
206
+ env_mode = next((e for e in project_config.environments if e.name == env), None)
207
+ if not env_mode:
208
+ if env == "prod":
209
+ if yes or Confirm.ask(
210
+ f"Environment '{env}' not found. Would you like to set it up now?"
211
+ ):
212
+ add_environment(project_config, env_name=env)
213
+ project_config = load_project_config()
214
+ env_mode = next(
215
+ (e for e in project_config.environments if e.name == env), None
216
+ )
217
+ if not env_mode:
218
+ console.print(
219
+ f"[red]Environment '{env}' was not created. Aborting.[/red]"
220
+ )
221
+ raise typer.Exit(code=1)
222
+ else:
223
+ console.print(
224
+ "[red]Aborting. Use 'flowstash env add' to create environments manually.[/red]"
225
+ )
226
+ raise typer.Exit(code=1)
227
+ else:
228
+ console.print(f"[red]Environment '{env}' not found.[/red]")
229
+ console.print(
230
+ f"[yellow]Available environments: {', '.join(e.name for e in project_config.environments)}[/yellow]"
231
+ )
232
+ console.print(
233
+ "To deploy to a specific environment, use: [bold]flowstash deploy <env_name>[/bold]"
234
+ )
235
+ raise typer.Exit(code=1)
236
+
237
+ # Ask for confirmation unless non-interactive is provided
238
+ if not yes:
239
+ if not Confirm.ask(f"Are you sure you want to deploy to '{env}'?"):
240
+ console.print("Deployment cancelled.")
241
+ raise typer.Exit(code=0)
242
+
243
+ # Check if env is managed
244
+ if not env_mode.managed:
245
+ console.print(
246
+ f"[red]Environment '{env}' is not managed. Deployment is only supported for managed environments.[/red]"
247
+ )
248
+ console.print(
249
+ "[yellow]Update your .flowstash environments if this is incorrect.[/yellow]"
250
+ )
251
+ raise typer.Exit(code=1)
252
+
253
+ project_id = project_config.project_id
254
+ if not project_id:
255
+ if not yes and Confirm.ask(
256
+ "Project ID not found. Would you like to link to a managed project now?"
257
+ ):
258
+ _link_project(project_config, user=user)
259
+ project_id = project_config.project_id
260
+
261
+ if not project_id:
262
+ console.print(
263
+ "[red]project_id not found in .flowstash. Use 'flowstash init' or link to a project.[/red]"
264
+ )
265
+ raise typer.Exit(code=1)
266
+
267
+ result = asyncio.run(run_deploy_flow(env, artifact, user=user))
268
+
269
+ api_url = result.get("api_url", "")
270
+ console.print("[green]✓ Deployment complete![/green]")
271
+ console.print(f" Deployment ID : [bold]{result['deploy_id']}[/bold]")
272
+ if api_url:
273
+ console.print(f" API URL : [bold]{api_url}[/bold]")
274
+
275
+ scheduled_tasks = result.get("scheduled_tasks")
276
+ if scheduled_tasks:
277
+ console.print(" Scheduled Tasks:")
278
+ for task in scheduled_tasks:
279
+ console.print(
280
+ f" - [cyan]{task['task_name']}[/cyan] : [yellow]{task['cron']}[/yellow]"
281
+ )
282
+ elif scheduled_tasks is not None:
283
+ console.print(" Scheduled Tasks: [dim]None[/dim]")
284
+
285
+
286
+ @app.command("configure")
287
+ def deploy_configure(
288
+ env: Optional[str] = typer.Option(
289
+ None, "--env", "-e", help="Environment to configure"
290
+ ),
291
+ profile: Optional[str] = typer.Option(
292
+ None, "--profile", "-p", help="Deployment profile name"
293
+ ),
294
+ always_on: Optional[bool] = typer.Option(
295
+ None,
296
+ "--always-on/--no-always-on",
297
+ help="Keep one worker warm (min_worker_instances 1) vs scale to zero (0)",
298
+ ),
299
+ apply: Optional[bool] = typer.Option(
300
+ None,
301
+ "--apply/--no-apply",
302
+ help="Apply to running services immediately (skips the prompt)",
303
+ ),
304
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompts"),
305
+ user: Optional[str] = typer.Option(
306
+ None, "--user", "-u", help="Account to use (default: project-linked or current)"
307
+ ),
308
+ ):
309
+ """
310
+ [bold cyan]Configure[/bold cyan] the deployment profile and scaling for an environment.
311
+
312
+ Interactively pick the environment, deployment profile, and whether the worker
313
+ stays always-on. Provide [bold]--env[/bold], [bold]--profile[/bold] and
314
+ [bold]--always-on/--no-always-on[/bold] to skip the prompts.
315
+ """
316
+ project_config, project_id, token = _resolve_project_and_token(user)
317
+ api = APIClient(token=token)
318
+
319
+ # 1. Environment selection (restricted to managed environments)
320
+ managed_envs = [e for e in project_config.environments if e.managed]
321
+ if env is None:
322
+ if not managed_envs:
323
+ console.print(
324
+ "[red]No managed environments found in .flowstash. "
325
+ "Add one with 'flowstash env add'.[/red]"
326
+ )
327
+ raise typer.Exit(code=1)
328
+ env = questionary.select(
329
+ "Environment to configure:",
330
+ choices=[e.name for e in managed_envs],
331
+ ).ask()
332
+ if not env:
333
+ console.print("Cancelled.")
334
+ raise typer.Exit(code=0)
335
+ else:
336
+ env_mode = next((e for e in project_config.environments if e.name == env), None)
337
+ if env_mode is not None and not env_mode.managed:
338
+ console.print(
339
+ f"[red]Environment '{env}' is not managed. Deployment configuration "
340
+ "is only supported for managed environments.[/red]"
341
+ )
342
+ raise typer.Exit(code=1)
343
+
344
+ # 2. Load available profiles + current config from the API
345
+ async def _load():
346
+ profiles = await api.get(
347
+ "/v1/deployment-profiles", params={"project_id": project_id}
348
+ )
349
+ try:
350
+ current = await api.get(
351
+ f"/v1/environments/{project_id}/{env}/deployment-config"
352
+ )
353
+ except Exception:
354
+ # First-time configuration: no stored config yet.
355
+ current = {}
356
+ return profiles, current
357
+
358
+ try:
359
+ profiles, current = asyncio.run(_load())
360
+ except Exception as e:
361
+ console.print(f"[red]Failed to load deployment profiles: {e}[/red]")
362
+ raise typer.Exit(code=1)
363
+
364
+ profile_names = [p.get("name") for p in (profiles or []) if p.get("name")]
365
+ current_profile = (current or {}).get("deployment_profile")
366
+ current_min = (current or {}).get("min_worker_instances") or 0
367
+
368
+ # 3. Profile selection
369
+ if profile is None:
370
+ if not profile_names:
371
+ console.print("[red]No deployment profiles available.[/red]")
372
+ raise typer.Exit(code=1)
373
+ spec_by_name = {p.get("name"): p.get("spec", {}) for p in profiles}
374
+ choices = []
375
+ for name in profile_names:
376
+ summary = _spec_summary(spec_by_name.get(name, {}))
377
+ label = f"{name} — {summary}" if summary else name
378
+ choices.append(questionary.Choice(label, value=name))
379
+ profile = questionary.select(
380
+ "Deployment profile:",
381
+ choices=choices,
382
+ default=current_profile if current_profile in profile_names else None,
383
+ ).ask()
384
+ if not profile:
385
+ console.print("Cancelled.")
386
+ raise typer.Exit(code=0)
387
+ elif profile_names and profile not in profile_names:
388
+ console.print(
389
+ f"[yellow]Warning: profile '{profile}' is not in the known list "
390
+ f"({', '.join(profile_names)}). Sending anyway.[/yellow]"
391
+ )
392
+
393
+ # 4. Always-on flag -> min_worker_instances (boolean: 1 / 0)
394
+ if always_on is None:
395
+ always_on = Confirm.ask(
396
+ "Keep a worker always on (no cold starts)?", default=current_min > 0
397
+ )
398
+ min_worker_instances = 1 if always_on else 0
399
+
400
+ # 5. Summary + confirmation
401
+ console.print()
402
+ console.print(f" Environment : [bold]{env}[/bold]")
403
+ console.print(f" Profile : [bold]{profile}[/bold]")
404
+ console.print(
405
+ f" Always on : [bold]{'yes' if always_on else 'no'}[/bold] "
406
+ f"(min_worker_instances={min_worker_instances})"
407
+ )
408
+ if not yes and not Confirm.ask("Save this deployment configuration?", default=True):
409
+ console.print("Cancelled.")
410
+ raise typer.Exit(code=0)
411
+
412
+ # 6. Persist server-side
413
+ async def _save():
414
+ return await api.request(
415
+ "PUT",
416
+ f"/v1/environments/{project_id}/{env}/deployment-config",
417
+ json={
418
+ "deployment_profile": profile,
419
+ "min_worker_instances": min_worker_instances,
420
+ },
421
+ )
422
+
423
+ try:
424
+ asyncio.run(_save())
425
+ except Exception as e:
426
+ console.print(f"[red]Failed to save deployment configuration: {e}[/red]")
427
+ raise typer.Exit(code=1)
428
+
429
+ console.print(f"[green]✓ Deployment configuration saved for '{env}'.[/green]")
430
+
431
+ # 7. Offer to apply to running services
432
+ if apply is None:
433
+ apply = Confirm.ask("Apply to running services now?", default=False)
434
+ if not apply:
435
+ console.print(
436
+ "[dim]Changes will take effect on the next 'flowstash deploy'.[/dim]"
437
+ )
438
+ return
439
+
440
+ try:
441
+ asyncio.run(_apply_profile(api, project_id, env))
442
+ except typer.Exit:
443
+ raise
444
+ except Exception as e:
445
+ console.print(f"[red]Failed to apply profile: {e}[/red]")
446
+ raise typer.Exit(code=1)
447
+
448
+
449
+ async def _apply_profile(api: APIClient, project_id: str, env: str):
450
+ """POST apply-profile and poll the resulting deploy, if any."""
451
+ res = await api.request(
452
+ "POST", f"/v1/environments/{project_id}/{env}/apply-profile"
453
+ )
454
+
455
+ if res.get("no_change"):
456
+ console.print(
457
+ f"[green]✓ {res.get('message', 'Profile configuration already applied.')}[/green]"
458
+ )
459
+ return
460
+
461
+ deploy_id = res.get("deploy_id")
462
+ if not deploy_id:
463
+ console.print(
464
+ f"[green]✓ {res.get('message', 'Profile update queued.')}[/green]"
465
+ )
466
+ return
467
+
468
+ with Progress(
469
+ SpinnerColumn(),
470
+ TextColumn("[progress.description]{task.description}"),
471
+ transient=True,
472
+ ) as progress:
473
+ task = progress.add_task(
474
+ description="Applying profile update...", total=None
475
+ )
476
+ result = await _poll_deploy_status(api, deploy_id, progress, task)
477
+
478
+ console.print("[green]✓ Profile update applied.[/green]")
479
+ console.print(f" Deployment ID : [bold]{result.get('deploy_id', deploy_id)}[/bold]")
@@ -71,6 +71,12 @@ app.add_typer(
71
71
  no_args_is_help=True,
72
72
  )
73
73
 
74
+ app.add_typer(
75
+ deploy_cmds.app,
76
+ name="deploy",
77
+ help="Deploy your project, or configure deployment ('configure')",
78
+ )
79
+
74
80
 
75
81
  @app.command()
76
82
  def help(ctx: typer.Context):
@@ -282,80 +288,6 @@ def build(
282
288
  build_cmds.build(env=env, tag=tag, user=user)
283
289
 
284
290
 
285
- @app.command()
286
- def deploy(
287
- ctx: typer.Context,
288
- env: str = typer.Argument("prod", help="Environment to deploy to (default: prod)"),
289
- artifact: Optional[str] = typer.Option(
290
- None, "--artifact", "-a", help="Artifact ID to deploy"
291
- ),
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
- ),
296
- ):
297
- """
298
- [bold cyan]Deploy[/bold cyan] your project to the flowstash Managed Platform.
299
-
300
- Defaults to the 'prod' environment. If 'prod' is missing, it will prompt you to set it up.
301
-
302
- [yellow]Note:[/yellow] To deploy to a specific environment, use: [bold]flowstash deploy <env_name>[/bold]
303
- """
304
- from .core.config import load_project_config
305
- from .commands.project import add_environment
306
- from rich.prompt import Confirm
307
-
308
- if ctx.get_parameter_source("env") == click.core.ParameterSource.DEFAULT:
309
- console.print(
310
- f"[dim]Env argument not specified... using [bold]{env}[/bold] as default[/dim]"
311
- )
312
-
313
- project_config = load_project_config()
314
- if not project_config:
315
- console.print(
316
- "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
317
- )
318
- raise typer.Exit(code=1)
319
-
320
- # Find the requested environment
321
- env_mode = next((e for e in project_config.environments if e.name == env), None)
322
-
323
- if not env_mode:
324
- if env == "prod":
325
- if yes or Confirm.ask(
326
- f"Environment '{env}' not found. Would you like to set it up now?"
327
- ):
328
- # We need to pass the actual project_config object to add_environment
329
- # But project_cmds is a module, we should be careful about cyclic imports or just use it.
330
- project_cmds.add_environment(project_config, env_name=env)
331
- # Reload or refresh check
332
- project_config = load_project_config()
333
- env_mode = next(
334
- (e for e in project_config.environments if e.name == env), None
335
- )
336
- if not env_mode:
337
- console.print(
338
- f"[red]Environment '{env}' was not created. Aborting.[/red]"
339
- )
340
- raise typer.Exit(code=1)
341
- else:
342
- console.print(
343
- f"[red]Aborting. Use 'flowstash env add' to create environments manually.[/red]"
344
- )
345
- raise typer.Exit(code=1)
346
- else:
347
- console.print(f"[red]Environment '{env}' not found.[/red]")
348
- console.print(
349
- f"[yellow]Available environments: {', '.join(e.name for e in project_config.environments)}[/yellow]"
350
- )
351
- console.print(
352
- f"To deploy to a specific environment, use: [bold]flowstash deploy <env_name>[/bold]"
353
- )
354
- raise typer.Exit(code=1)
355
-
356
- deploy_cmds.deploy(env=env, artifact=artifact, yes=yes, user=user)
357
-
358
-
359
291
  @app.command()
360
292
  def whoami(
361
293
  user: Optional[str] = typer.Option(
@@ -1,178 +0,0 @@
1
- from typing import Optional
2
- import asyncio
3
- import typer
4
- from pathlib import Path
5
- from rich.console import Console
6
- from rich.progress import Progress, SpinnerColumn, TextColumn
7
- from ..core.api_client import APIClient
8
- from ..core.config import load_project_config, resolve_credentials
9
- from .build import run_build_flow
10
- import flowstash.runtime
11
-
12
- app = typer.Typer()
13
- console = Console()
14
-
15
- # Status labels shown to the user while polling
16
- _STATUS_LABELS = {
17
- "QUEUED": "Queued, waiting for deployment to start...",
18
- "VALIDATING": "Validating container images...",
19
- "DEPLOYING": "Deploying services...",
20
- "HEALTH_CHECK": "Health-checking API and Worker...",
21
- "SYNCING_SCHEDULES": "Waiting for deployment verification...",
22
- "DEPLOYED": "Deployed successfully ✓",
23
- "FAILED": "Deployment failed.",
24
- }
25
-
26
- _TERMINAL_STATUSES = {"DEPLOYED", "FAILED"}
27
-
28
-
29
- async def run_deploy_flow(
30
- env: str, artifact_id: Optional[str] = None, user: Optional[str] = None
31
- ):
32
- project_config = load_project_config()
33
- if not project_config:
34
- console.print(
35
- "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
36
- )
37
- raise typer.Exit(code=1)
38
-
39
- project_id = project_config.project_id
40
- if not project_id:
41
- console.print("[red]project_id not found in .flowstash[/red]")
42
- raise typer.Exit(code=1)
43
-
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)
50
-
51
- # 1. If artifact_id is not provided, run build first
52
- if not artifact_id:
53
- console.print("No artifact ID provided. Building first...")
54
- build_result = await run_build_flow(user=user)
55
- artifact_id = build_result["artifact_id"]
56
- console.print(f"Build finished. Deploying artifact: [bold]{artifact_id}[/bold]")
57
-
58
- with Progress(
59
- SpinnerColumn(),
60
- TextColumn("[progress.description]{task.description}"),
61
- transient=True,
62
- ) as progress:
63
- # 2. Trigger deployment
64
- task = progress.add_task(description="Triggering deployment...", total=None)
65
- deploy_data = await api.post(
66
- "/v1/deploy",
67
- json={
68
- "project_id": project_id,
69
- "artifact_id": artifact_id,
70
- "env_vars": {"ENVIRONMENT": env},
71
- "flowstash_runtime_version": flowstash.runtime.__version__,
72
- },
73
- )
74
- deploy_id = deploy_data["deploy_id"]
75
- progress.update(task, description=f"Deployment triggered (ID: {deploy_id}).")
76
-
77
- # 3. Poll status until terminal
78
- last_status = None
79
- while True:
80
- status_data = await api.get(f"/v1/deploy/{deploy_id}/status")
81
- current_status = status_data["status"]
82
-
83
- if current_status != last_status:
84
- label = _STATUS_LABELS.get(current_status, f"Status: {current_status}")
85
- progress.update(task, description=label)
86
- last_status = current_status
87
-
88
- if current_status == "DEPLOYED":
89
- break
90
- elif current_status == "FAILED":
91
- error = status_data.get("error_message", "Unknown error")
92
- console.print(f"[red]Deployment failed: {error}[/red]")
93
- raise typer.Exit(code=1)
94
-
95
- await asyncio.sleep(5)
96
-
97
- return status_data
98
-
99
-
100
- @app.command()
101
- def deploy(
102
- env: str = typer.Argument(..., help="Environment to deploy to"),
103
- artifact: Optional[str] = typer.Option(
104
- None, "--artifact", "-a", help="Artifact ID to deploy"
105
- ),
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
- ),
110
- ):
111
- """Deploy an artifact to the managed platform for a specified environment."""
112
- project_config = load_project_config()
113
- if not project_config:
114
- console.print(
115
- "[red]No .flowstash found. Please run 'flowstash init' first.[/red]"
116
- )
117
- raise typer.Exit(code=1)
118
-
119
- # Ask for confirmation unless non-interactive is provided
120
- if not yes:
121
- from rich.prompt import Confirm
122
-
123
- if not Confirm.ask(f"Are you sure you want to deploy to '{env}'?"):
124
- console.print("Deployment cancelled.")
125
- raise typer.Exit(code=0)
126
-
127
- # Check if env is managed
128
- is_managed = False
129
- for em in project_config.environments:
130
- if em.name == env:
131
- is_managed = em.managed
132
- break
133
-
134
- if not is_managed:
135
- console.print(
136
- f"[red]Environment '{env}' is not managed. Deployment is only supported for managed environments.[/red]"
137
- )
138
- console.print(
139
- "[yellow]Update your .flowstash environments if this is incorrect.[/yellow]"
140
- )
141
- raise typer.Exit(code=1)
142
-
143
- project_id = project_config.project_id
144
- if not project_id:
145
- if not yes:
146
- from rich.prompt import Confirm
147
-
148
- if Confirm.ask(
149
- "Project ID not found. Would you like to link to a managed project now?"
150
- ):
151
- from .project import _link_project
152
-
153
- _link_project(project_config, user=user)
154
- project_id = project_config.project_id
155
-
156
- if not project_id:
157
- console.print(
158
- "[red]project_id not found in .flowstash. Use 'flowstash init' or link to a project.[/red]"
159
- )
160
- raise typer.Exit(code=1)
161
-
162
- result = asyncio.run(run_deploy_flow(env, artifact, user=user))
163
-
164
- api_url = result.get("api_url", "")
165
- console.print(f"[green]✓ Deployment complete![/green]")
166
- console.print(f" Deployment ID : [bold]{result['deploy_id']}[/bold]")
167
- if api_url:
168
- console.print(f" API URL : [bold]{api_url}[/bold]")
169
-
170
- scheduled_tasks = result.get("scheduled_tasks")
171
- if scheduled_tasks:
172
- console.print(" Scheduled Tasks:")
173
- for task in scheduled_tasks:
174
- console.print(
175
- f" - [cyan]{task['task_name']}[/cyan] : [yellow]{task['cron']}[/yellow]"
176
- )
177
- elif scheduled_tasks is not None:
178
- console.print(" Scheduled Tasks: [dim]None[/dim]")