pywire-cli 0.2.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.
- pywire_cli/__init__.py +3 -0
- pywire_cli/check.py +117 -0
- pywire_cli/config.py +144 -0
- pywire_cli/deploy.py +90 -0
- pywire_cli/main.py +894 -0
- pywire_cli/tui.py +574 -0
- pywire_cli-0.2.0.dist-info/METADATA +56 -0
- pywire_cli-0.2.0.dist-info/RECORD +10 -0
- pywire_cli-0.2.0.dist-info/WHEEL +4 -0
- pywire_cli-0.2.0.dist-info/entry_points.txt +2 -0
pywire_cli/main.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Main CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import rich.panel
|
|
10
|
+
import rich_click as click
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
except ImportError:
|
|
13
|
+
print(
|
|
14
|
+
"Error: pywire CLI requires additional dependencies.\n"
|
|
15
|
+
"Install them with: uv add pywire[cli] (or: pip install pywire[cli])",
|
|
16
|
+
file=sys.stderr,
|
|
17
|
+
)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
|
|
20
|
+
from pywire import __version__
|
|
21
|
+
from pywire_cli.config import config_command
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
# Astro-like styling configuration (Cyan Theme)
|
|
26
|
+
click.rich_click.USE_RICH_MARKUP = True
|
|
27
|
+
click.rich_click.STYLE_HELPTEXT_FIRST = True
|
|
28
|
+
click.rich_click.STYLE_COMMANDS_TABLE_SHOW_LINES = False
|
|
29
|
+
click.rich_click.STYLE_COMMANDS_TABLE_PAD_EDGE = False
|
|
30
|
+
click.rich_click.STYLE_COMMANDS_TABLE_BOX = None
|
|
31
|
+
click.rich_click.STYLE_COMMANDS_TABLE_EXPAND = False
|
|
32
|
+
click.rich_click.STYLE_OPTIONS_TABLE_EXPAND = False
|
|
33
|
+
click.rich_click.STYLE_COMMANDS_TABLE_HEADER = "bold magenta"
|
|
34
|
+
click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = None
|
|
35
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
36
|
+
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
|
|
37
|
+
click.rich_click.STYLE_ERRORS_SUGGESTION = "magenta italic"
|
|
38
|
+
click.rich_click.ERRORS_SUGGESTION = "Try running 'pywire --help' for more information."
|
|
39
|
+
click.rich_click.ERRORS_EPILOGUE = "To find out more, visit [link=https://github.com/pywire/pywire]https://github.com/pywire/pywire[/link]"
|
|
40
|
+
click.rich_click.STYLE_OPTIONS_TABLE_BOX = None
|
|
41
|
+
click.rich_click.STYLE_COMMANDS_PANEL_BOX = None
|
|
42
|
+
click.rich_click.STYLE_OPTIONS_PANEL_BOX = None
|
|
43
|
+
|
|
44
|
+
# Cyan theme
|
|
45
|
+
click.rich_click.STYLE_HEADER_TEXT = "bold cyan"
|
|
46
|
+
click.rich_click.STYLE_OPTION = "cyan"
|
|
47
|
+
click.rich_click.STYLE_SWITCH = "cyan"
|
|
48
|
+
click.rich_click.STYLE_METAVAR = "dim white"
|
|
49
|
+
click.rich_click.STYLE_USAGE_COMMAND = "cyan"
|
|
50
|
+
click.rich_click.STYLE_USAGE = "dim"
|
|
51
|
+
|
|
52
|
+
# Grouping options and commands
|
|
53
|
+
click.rich_click.OPTION_GROUPS = {
|
|
54
|
+
"pywire": [
|
|
55
|
+
{
|
|
56
|
+
"name": "Global Flags",
|
|
57
|
+
"options": ["--help", "--version"],
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
click.rich_click.COMMAND_GROUPS = {
|
|
63
|
+
"pywire": [
|
|
64
|
+
{
|
|
65
|
+
"name": "Commands",
|
|
66
|
+
"commands": ["dev", "run", "build", "check", "deploy"],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "Configuration",
|
|
70
|
+
"commands": ["config"],
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _setup_import_paths(module_name: str) -> None:
|
|
77
|
+
"""Configure sys.path so a dotted module string and its sibling imports resolve.
|
|
78
|
+
|
|
79
|
+
For ``src.main``, both the project root (so ``src`` is a package) and
|
|
80
|
+
``src/`` itself (so ``from auth_middleware import …`` works inside main.py)
|
|
81
|
+
are prepended. Works for arbitrary nesting depth.
|
|
82
|
+
"""
|
|
83
|
+
cwd = os.getcwd()
|
|
84
|
+
if cwd not in sys.path:
|
|
85
|
+
sys.path.insert(0, cwd)
|
|
86
|
+
|
|
87
|
+
# Add every intermediate directory so that relative-style imports within
|
|
88
|
+
# each layer work without requiring the full dotted prefix.
|
|
89
|
+
parts = module_name.split(".")
|
|
90
|
+
for depth in range(1, len(parts)):
|
|
91
|
+
subdir = os.path.join(cwd, *parts[:depth])
|
|
92
|
+
if os.path.isdir(subdir) and subdir not in sys.path:
|
|
93
|
+
sys.path.insert(0, subdir)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def import_app(app_str: str) -> Any:
|
|
97
|
+
"""Import application from string (e.g. 'main:app' or 'src.main:app')."""
|
|
98
|
+
if ":" not in app_str:
|
|
99
|
+
raise click.BadParameter("App must be in format 'module:app'", param_hint="APP")
|
|
100
|
+
|
|
101
|
+
module_name, app_name = app_str.split(":", 1)
|
|
102
|
+
|
|
103
|
+
_setup_import_paths(module_name)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
import importlib
|
|
107
|
+
|
|
108
|
+
module = importlib.import_module(module_name)
|
|
109
|
+
except ImportError as e:
|
|
110
|
+
raise click.BadParameter(
|
|
111
|
+
f"Could not import module '{module_name}': {e}", param_hint="APP"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
app = getattr(module, app_name)
|
|
116
|
+
except AttributeError:
|
|
117
|
+
raise click.BadParameter(
|
|
118
|
+
f"Attribute '{app_name}' not found in module '{module_name}'",
|
|
119
|
+
param_hint="APP",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return app
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _discover_app_str() -> str:
|
|
126
|
+
"""Try to discover the app string automatically."""
|
|
127
|
+
cwd = Path(os.getcwd())
|
|
128
|
+
|
|
129
|
+
# Priority: main.py, app.py, api.py
|
|
130
|
+
# Also check src/ directory
|
|
131
|
+
search_paths = [cwd, cwd / "src"]
|
|
132
|
+
|
|
133
|
+
for path in search_paths:
|
|
134
|
+
if not path.exists():
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
for filename in ["main.py", "app.py", "api.py"]:
|
|
138
|
+
if (path / filename).exists():
|
|
139
|
+
# Check for common app instance names: app, api
|
|
140
|
+
module_name = filename[:-3]
|
|
141
|
+
|
|
142
|
+
# Construct module path (e.g. src.main)
|
|
143
|
+
if path.name == "src":
|
|
144
|
+
module_path = f"src.{module_name}"
|
|
145
|
+
else:
|
|
146
|
+
module_path = module_name
|
|
147
|
+
|
|
148
|
+
# Simple check: try to import and look for app
|
|
149
|
+
try:
|
|
150
|
+
_setup_import_paths(module_path)
|
|
151
|
+
import importlib
|
|
152
|
+
|
|
153
|
+
module = importlib.import_module(module_path)
|
|
154
|
+
|
|
155
|
+
if hasattr(module, "app"):
|
|
156
|
+
return f"{module_path}:app"
|
|
157
|
+
if hasattr(module, "api"):
|
|
158
|
+
return f"{module_path}:api"
|
|
159
|
+
|
|
160
|
+
except ImportError:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
raise click.UsageError(
|
|
164
|
+
"Could not auto-discover app. Please provide 'APP' argument (e.g. 'main:app')."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Workaround: rich-click wraps tables in Panels which default to expand=True.
|
|
169
|
+
# We monkeypatch Panel to default expand=False to allow natural resizing.
|
|
170
|
+
original_panel_init = rich.panel.Panel.__init__
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def panel_init(self, *args, **kwargs):
|
|
174
|
+
kwargs.setdefault("expand", False)
|
|
175
|
+
original_panel_init(self, *args, **kwargs)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
rich.panel.Panel.__init__ = panel_init # type: ignore[method-assign] # ty: ignore[invalid-assignment]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _find_available_port(host: str, port: int, max_attempts: int = 100) -> int:
|
|
182
|
+
"""Find an available port starting from 'port'."""
|
|
183
|
+
import socket
|
|
184
|
+
|
|
185
|
+
# Try to determine if we should use IPv4 or IPv6
|
|
186
|
+
try:
|
|
187
|
+
addr_info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
188
|
+
family = addr_info[0][0]
|
|
189
|
+
except Exception:
|
|
190
|
+
family = socket.AF_INET # Fallback
|
|
191
|
+
|
|
192
|
+
for p in range(port, port + max_attempts):
|
|
193
|
+
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
194
|
+
try:
|
|
195
|
+
s.bind((host, p))
|
|
196
|
+
return p
|
|
197
|
+
except OSError:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
raise click.UsageError(
|
|
201
|
+
f"Could not find an available port starting from {port} after {max_attempts} attempts."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@click.group(
|
|
206
|
+
help=f"""
|
|
207
|
+
[bold white on cyan] pywire [/] [bold cyan]v{__version__}[/] Build faster python web apps.
|
|
208
|
+
|
|
209
|
+
Run [bold cyan]pywire dev APP[/] to start development server.
|
|
210
|
+
Run [bold cyan]pywire run APP[/] to start production server.
|
|
211
|
+
|
|
212
|
+
[dim]APP should be a string in format 'module:instance', e.g. 'src.main:app' or 'main:app'
|
|
213
|
+
If not provided, pywire tries to discover it in main.py, app.py, etc.[/dim]
|
|
214
|
+
"""
|
|
215
|
+
)
|
|
216
|
+
@click.version_option(__version__)
|
|
217
|
+
def cli() -> None:
|
|
218
|
+
# Run pywire's .env cascade before any subcommand so app-import-time
|
|
219
|
+
# code (provider constructors, LocalIdP, direct os.environ reads in
|
|
220
|
+
# main.py, etc.) sees the env vars populated.
|
|
221
|
+
from pywire.config import _ensure_loaded
|
|
222
|
+
|
|
223
|
+
_ensure_loaded()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
cli.add_command(config_command)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@cli.command()
|
|
230
|
+
@click.argument("app", required=False)
|
|
231
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
|
232
|
+
@click.option(
|
|
233
|
+
"--port", default=None, type=int, help="Port to bind to (default: 3000 or config)"
|
|
234
|
+
)
|
|
235
|
+
@click.option("--ssl-keyfile", default=None, help="SSL key file")
|
|
236
|
+
@click.option("--ssl-certfile", default=None, help="SSL certificate file")
|
|
237
|
+
@click.option("--env-file", default=None, help="Environment configuration file")
|
|
238
|
+
@click.option("--tui/--no-tui", default=None, help="Enable/disable TUI dashboard")
|
|
239
|
+
def dev(
|
|
240
|
+
app: Optional[str],
|
|
241
|
+
host: str,
|
|
242
|
+
port: Optional[int],
|
|
243
|
+
ssl_keyfile: Optional[str],
|
|
244
|
+
ssl_certfile: Optional[str],
|
|
245
|
+
env_file: Optional[str],
|
|
246
|
+
tui: Optional[bool],
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Start development server."""
|
|
249
|
+
import asyncio
|
|
250
|
+
|
|
251
|
+
from pywire_cli.config import get_setting
|
|
252
|
+
from pywire.runtime.dev_server import run_dev_server
|
|
253
|
+
|
|
254
|
+
# Resolve TUI setting: CLI flag > settings.toml > default (False)
|
|
255
|
+
if tui is None:
|
|
256
|
+
saved = get_setting("tui")
|
|
257
|
+
use_tui = saved if isinstance(saved, bool) else False
|
|
258
|
+
else:
|
|
259
|
+
use_tui = tui
|
|
260
|
+
|
|
261
|
+
# Resolve port: CLI flag > settings.toml > default (3000)
|
|
262
|
+
if port is None:
|
|
263
|
+
saved_port = get_setting("port")
|
|
264
|
+
port = int(saved_port) if saved_port is not None else 3000
|
|
265
|
+
assert port is not None
|
|
266
|
+
|
|
267
|
+
if not app:
|
|
268
|
+
app = _discover_app_str()
|
|
269
|
+
|
|
270
|
+
# .env already loaded in the cli group callback (see pywire.config
|
|
271
|
+
# cascade). --env-file is a forward-compat flag consumed by the TUI
|
|
272
|
+
# subprocess path below.
|
|
273
|
+
import_app(app)
|
|
274
|
+
|
|
275
|
+
# Find available port
|
|
276
|
+
original_port = port
|
|
277
|
+
port = _find_available_port(host, port)
|
|
278
|
+
|
|
279
|
+
if not use_tui:
|
|
280
|
+
asyncio.run(
|
|
281
|
+
run_dev_server(
|
|
282
|
+
app_str=app, # Pass string for reloadability hooks if needed
|
|
283
|
+
host=host,
|
|
284
|
+
port=port,
|
|
285
|
+
ssl_keyfile=ssl_keyfile,
|
|
286
|
+
ssl_certfile=ssl_certfile,
|
|
287
|
+
original_port=original_port,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
from pywire_cli.tui import start_tui
|
|
292
|
+
|
|
293
|
+
start_tui(
|
|
294
|
+
app_path=app,
|
|
295
|
+
host=host,
|
|
296
|
+
port=port,
|
|
297
|
+
ssl_keyfile=ssl_keyfile,
|
|
298
|
+
ssl_certfile=ssl_certfile,
|
|
299
|
+
env_file=env_file,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@cli.command()
|
|
304
|
+
@click.argument("app", required=False)
|
|
305
|
+
@click.option(
|
|
306
|
+
"--optimize",
|
|
307
|
+
is_flag=True,
|
|
308
|
+
help="Compile bytecode artifacts for faster import.",
|
|
309
|
+
)
|
|
310
|
+
@click.option(
|
|
311
|
+
"--out-dir",
|
|
312
|
+
default=".pywire/build",
|
|
313
|
+
help="Output directory for build artifacts.",
|
|
314
|
+
)
|
|
315
|
+
@click.option(
|
|
316
|
+
"--pages-dir",
|
|
317
|
+
default=None,
|
|
318
|
+
help="Override pages directory (default: app.pages_dir).",
|
|
319
|
+
)
|
|
320
|
+
@click.option(
|
|
321
|
+
"--platform",
|
|
322
|
+
type=click.Choice(["cloudflare"]),
|
|
323
|
+
default=None,
|
|
324
|
+
help="Generate platform-specific build output.",
|
|
325
|
+
)
|
|
326
|
+
def build(
|
|
327
|
+
app: Optional[str],
|
|
328
|
+
optimize: bool,
|
|
329
|
+
out_dir: str,
|
|
330
|
+
pages_dir: Optional[str],
|
|
331
|
+
platform: Optional[str],
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Build the application for production."""
|
|
334
|
+
if not app:
|
|
335
|
+
app = _discover_app_str()
|
|
336
|
+
|
|
337
|
+
console.print(f"🔨 Building [cyan]{app}[/]...")
|
|
338
|
+
|
|
339
|
+
app_instance = import_app(app)
|
|
340
|
+
|
|
341
|
+
if pages_dir:
|
|
342
|
+
resolved_pages_dir = Path(pages_dir)
|
|
343
|
+
elif hasattr(app_instance, "pages_dir"):
|
|
344
|
+
resolved_pages_dir = Path(app_instance.pages_dir)
|
|
345
|
+
else:
|
|
346
|
+
resolved_pages_dir = Path("pages")
|
|
347
|
+
|
|
348
|
+
from pywire.compiler.build import build_project
|
|
349
|
+
from pywire_cli.check import collect_diagnostics, format_rich, summarize
|
|
350
|
+
|
|
351
|
+
pre_diags = collect_diagnostics(resolved_pages_dir)
|
|
352
|
+
pre_summary = summarize(pre_diags)
|
|
353
|
+
if pre_diags:
|
|
354
|
+
format_rich(pre_diags, console)
|
|
355
|
+
if pre_summary.errors:
|
|
356
|
+
console.print(
|
|
357
|
+
f"\n[bold red]Build blocked: "
|
|
358
|
+
f"{pre_summary.errors} analysis error(s).[/] "
|
|
359
|
+
"Fix them or run [cyan]pywire check[/] for details."
|
|
360
|
+
)
|
|
361
|
+
sys.exit(1)
|
|
362
|
+
console.print(
|
|
363
|
+
f"[yellow]Continuing with "
|
|
364
|
+
f"{pre_summary.warnings} warning(s), "
|
|
365
|
+
f"{pre_summary.infos} info(s)[/]"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Resolve static_dir for asset fingerprinting
|
|
369
|
+
resolved_static_dir = None
|
|
370
|
+
if hasattr(app_instance, "static_dir") and app_instance.static_dir:
|
|
371
|
+
resolved_static_dir = Path(app_instance.static_dir)
|
|
372
|
+
|
|
373
|
+
summary = build_project(
|
|
374
|
+
optimize=optimize,
|
|
375
|
+
pages_dir=resolved_pages_dir,
|
|
376
|
+
out_dir=Path(out_dir),
|
|
377
|
+
static_dir=resolved_static_dir,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
parts = [
|
|
381
|
+
f"pages={summary.pages}",
|
|
382
|
+
f"layouts={summary.layouts}",
|
|
383
|
+
f"components={summary.components}",
|
|
384
|
+
]
|
|
385
|
+
if summary.static_assets > 0:
|
|
386
|
+
parts.append(f"static_assets={summary.static_assets}")
|
|
387
|
+
parts.append(f"out={summary.out_dir}")
|
|
388
|
+
|
|
389
|
+
console.print(f"✅ Build complete ({', '.join(parts)})")
|
|
390
|
+
|
|
391
|
+
if platform == "cloudflare":
|
|
392
|
+
import shutil
|
|
393
|
+
|
|
394
|
+
from pywire.compiler.build_artifacts import generate_cf_bundle
|
|
395
|
+
|
|
396
|
+
cf_bundle_dir = Path.cwd() / "_pywire_build"
|
|
397
|
+
routes_path = generate_cf_bundle(
|
|
398
|
+
build_dir=Path(out_dir),
|
|
399
|
+
cf_bundle_dir=cf_bundle_dir,
|
|
400
|
+
app_import=app,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Copy static assets to .pywire/deploy/public/ for Cloudflare's
|
|
404
|
+
# native static assets binding (served from edge CDN, not the Worker).
|
|
405
|
+
# The wrangler.toml [assets] directive points to .pywire/deploy/public.
|
|
406
|
+
deploy_public = Path.cwd() / ".pywire" / "deploy" / "public"
|
|
407
|
+
if deploy_public.exists():
|
|
408
|
+
shutil.rmtree(deploy_public)
|
|
409
|
+
|
|
410
|
+
# PyWire framework JS — resolve from installed pywire package, not
|
|
411
|
+
# from this file's location (pywire-cli now lives in its own package).
|
|
412
|
+
import pywire
|
|
413
|
+
|
|
414
|
+
pywire_static_src = Path(pywire.__file__).parent / "static"
|
|
415
|
+
pywire_static_dest = deploy_public / "_pywire" / "static"
|
|
416
|
+
if pywire_static_src.exists():
|
|
417
|
+
pywire_static_dest.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
for f in pywire_static_src.iterdir():
|
|
419
|
+
if f.is_file() and (f.suffix in (".js", ".css", ".map")):
|
|
420
|
+
shutil.copy2(f, pywire_static_dest / f.name)
|
|
421
|
+
|
|
422
|
+
# User static files — respect the app's configured static_url_path
|
|
423
|
+
user_static = app_instance.static_dir if app_instance else None
|
|
424
|
+
static_url_path = getattr(app_instance, "static_url_path", "/static")
|
|
425
|
+
# Strip leading slash to make it a relative path for the deploy dir
|
|
426
|
+
static_subdir = static_url_path.lstrip("/")
|
|
427
|
+
if user_static and Path(user_static).exists() and Path(user_static).is_dir():
|
|
428
|
+
user_static_dest = deploy_public / static_subdir
|
|
429
|
+
shutil.copytree(user_static, user_static_dest)
|
|
430
|
+
|
|
431
|
+
# Regenerate pywire_do.py (contains app import path)
|
|
432
|
+
from pywire_cli.deploy import generate_cf_durable_object
|
|
433
|
+
|
|
434
|
+
do_content = generate_cf_durable_object(Path.cwd(), app or "src.main:app")
|
|
435
|
+
(Path.cwd() / "pywire_do.py").write_text(do_content)
|
|
436
|
+
|
|
437
|
+
console.print(
|
|
438
|
+
f"✅ Generated [cyan]_pywire_build/[/], [cyan]{routes_path.name}[/], "
|
|
439
|
+
f"and [cyan]pywire_do.py[/] for Cloudflare Workers"
|
|
440
|
+
)
|
|
441
|
+
console.print(
|
|
442
|
+
"✅ Static assets → [cyan].pywire/deploy/public/[/] "
|
|
443
|
+
"(served by Cloudflare edge CDN)"
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@cli.command()
|
|
448
|
+
@click.argument("app", required=False)
|
|
449
|
+
@click.option(
|
|
450
|
+
"--pages-dir",
|
|
451
|
+
default=None,
|
|
452
|
+
help="Override pages directory (default: app.pages_dir or ./pages).",
|
|
453
|
+
)
|
|
454
|
+
@click.option(
|
|
455
|
+
"--rule",
|
|
456
|
+
"rules",
|
|
457
|
+
multiple=True,
|
|
458
|
+
help="Only run specific rules (e.g. --rule PW001 --rule PW003). Default: all.",
|
|
459
|
+
)
|
|
460
|
+
@click.option(
|
|
461
|
+
"--plain",
|
|
462
|
+
is_flag=True,
|
|
463
|
+
help="Emit ruff-style plain text (file:line:col: severity [code] message).",
|
|
464
|
+
)
|
|
465
|
+
@click.option(
|
|
466
|
+
"--strict",
|
|
467
|
+
is_flag=True,
|
|
468
|
+
help="Exit 1 on any warning or info (CI mode). Default exits 1 only on errors.",
|
|
469
|
+
)
|
|
470
|
+
@click.option(
|
|
471
|
+
"--fix",
|
|
472
|
+
is_flag=True,
|
|
473
|
+
help="(Stub — not implemented yet) Attempt to apply suggested fixes.",
|
|
474
|
+
)
|
|
475
|
+
def check(
|
|
476
|
+
app: Optional[str],
|
|
477
|
+
pages_dir: Optional[str],
|
|
478
|
+
rules: tuple[str, ...],
|
|
479
|
+
plain: bool,
|
|
480
|
+
strict: bool,
|
|
481
|
+
fix: bool,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Run static analysis on a PyWire project."""
|
|
484
|
+
from pywire_cli.check import (
|
|
485
|
+
collect_diagnostics,
|
|
486
|
+
format_plain,
|
|
487
|
+
format_rich,
|
|
488
|
+
summarize,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if fix:
|
|
492
|
+
console.print(
|
|
493
|
+
"[yellow]--fix is not implemented yet.[/] "
|
|
494
|
+
"See pywire_parser/analysis/ROADMAP.md for the fix protocol plan."
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if pages_dir:
|
|
498
|
+
resolved = Path(pages_dir)
|
|
499
|
+
else:
|
|
500
|
+
if not app:
|
|
501
|
+
try:
|
|
502
|
+
app = _discover_app_str()
|
|
503
|
+
except Exception:
|
|
504
|
+
app = None
|
|
505
|
+
if app:
|
|
506
|
+
try:
|
|
507
|
+
inst = import_app(app)
|
|
508
|
+
resolved = (
|
|
509
|
+
Path(inst.pages_dir)
|
|
510
|
+
if hasattr(inst, "pages_dir")
|
|
511
|
+
else Path("pages")
|
|
512
|
+
)
|
|
513
|
+
except Exception:
|
|
514
|
+
resolved = Path("pages")
|
|
515
|
+
else:
|
|
516
|
+
resolved = Path("pages")
|
|
517
|
+
|
|
518
|
+
rule_codes = list(rules) if rules else None
|
|
519
|
+
diags = collect_diagnostics(resolved, rule_codes=rule_codes)
|
|
520
|
+
|
|
521
|
+
if plain:
|
|
522
|
+
if diags:
|
|
523
|
+
click.echo(format_plain(diags))
|
|
524
|
+
else:
|
|
525
|
+
if not diags:
|
|
526
|
+
console.print("[green]✓ No issues found.[/]")
|
|
527
|
+
else:
|
|
528
|
+
format_rich(diags, console)
|
|
529
|
+
|
|
530
|
+
summary = summarize(diags, strict=strict)
|
|
531
|
+
if not plain and summary.total > 0:
|
|
532
|
+
console.print(
|
|
533
|
+
f"\n[dim]{summary.errors} error(s), "
|
|
534
|
+
f"{summary.warnings} warning(s), "
|
|
535
|
+
f"{summary.infos} info(s)[/]"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if summary.exit_code != 0:
|
|
539
|
+
sys.exit(summary.exit_code)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@cli.command()
|
|
543
|
+
@click.argument("app", required=False)
|
|
544
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to")
|
|
545
|
+
@click.option("--port", default=8000, type=int, help="Port to bind to")
|
|
546
|
+
@click.option("--workers", default=None, type=int, help="Number of worker processes")
|
|
547
|
+
@click.option("--no-access-log", is_flag=True, help="Disable access logging")
|
|
548
|
+
def run(
|
|
549
|
+
app: Optional[str],
|
|
550
|
+
host: str,
|
|
551
|
+
port: int,
|
|
552
|
+
workers: Optional[int],
|
|
553
|
+
no_access_log: bool,
|
|
554
|
+
) -> None:
|
|
555
|
+
"""Run production server using Uvicorn."""
|
|
556
|
+
import multiprocessing
|
|
557
|
+
|
|
558
|
+
import uvicorn
|
|
559
|
+
|
|
560
|
+
if not app:
|
|
561
|
+
app = _discover_app_str()
|
|
562
|
+
click.echo(f"🔍 Auto-discovered app: {app}")
|
|
563
|
+
|
|
564
|
+
if workers is None:
|
|
565
|
+
workers = (multiprocessing.cpu_count() * 2) + 1
|
|
566
|
+
|
|
567
|
+
console.print(f"🚀 Starting [bold]production[/] server for [cyan]{app}[/]")
|
|
568
|
+
console.print(
|
|
569
|
+
f"🌍 Listening on [link=http://{host}:{port}]http://{host}:{port}[/link]"
|
|
570
|
+
)
|
|
571
|
+
console.print(f"👷 Workers: {workers}")
|
|
572
|
+
|
|
573
|
+
# Locate the app object to verify, but pass string to uvicorn
|
|
574
|
+
import_app(app)
|
|
575
|
+
|
|
576
|
+
uvicorn.run(
|
|
577
|
+
app,
|
|
578
|
+
host=host,
|
|
579
|
+
port=port,
|
|
580
|
+
workers=workers,
|
|
581
|
+
access_log=not no_access_log,
|
|
582
|
+
factory=False,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _print_skip_hint(
|
|
587
|
+
filename: str,
|
|
588
|
+
platform: str,
|
|
589
|
+
workers: int,
|
|
590
|
+
redis: bool,
|
|
591
|
+
project_name: str,
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Print manual instructions when a file overwrite is declined."""
|
|
594
|
+
if filename == "Dockerfile":
|
|
595
|
+
console.print(
|
|
596
|
+
f" [dim]To apply [cyan]--workers {workers}[/], update your Dockerfile CMD:[/]\n"
|
|
597
|
+
f' [dim] CMD ["uv", "run", "pywire", "run", "--host", "0.0.0.0",'
|
|
598
|
+
f' "--port", "8000", "--workers", "{workers}"][/]'
|
|
599
|
+
)
|
|
600
|
+
elif filename == "render.yaml" and redis:
|
|
601
|
+
console.print(
|
|
602
|
+
" [dim]To add Redis manually, add to [cyan]render.yaml[/]:[/]\n"
|
|
603
|
+
" [dim] - type: keyvalue[/]\n"
|
|
604
|
+
f" [dim] name: {project_name}-kv[/]\n"
|
|
605
|
+
" [dim] plan: starter[/]\n"
|
|
606
|
+
" [dim] ipAllowList: [][/]\n"
|
|
607
|
+
" [dim]And bind REDIS_URL in your web service envVars.[/]"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@cli.command()
|
|
612
|
+
@click.argument("app", required=False)
|
|
613
|
+
@click.option(
|
|
614
|
+
"--platform",
|
|
615
|
+
type=click.Choice(["render", "docker", "fly", "railway", "cloudflare"]),
|
|
616
|
+
default="docker",
|
|
617
|
+
help="Deployment platform",
|
|
618
|
+
)
|
|
619
|
+
@click.option(
|
|
620
|
+
"--out-dir",
|
|
621
|
+
default=".",
|
|
622
|
+
type=click.Path(),
|
|
623
|
+
help="Output directory for deploy configs",
|
|
624
|
+
)
|
|
625
|
+
@click.option(
|
|
626
|
+
"--workers",
|
|
627
|
+
default=1,
|
|
628
|
+
type=int,
|
|
629
|
+
help="Number of worker processes (default: 1)",
|
|
630
|
+
)
|
|
631
|
+
@click.option(
|
|
632
|
+
"--redis",
|
|
633
|
+
is_flag=True,
|
|
634
|
+
help="Include Redis/Valkey KV store in deployment config",
|
|
635
|
+
)
|
|
636
|
+
def deploy(
|
|
637
|
+
app: Optional[str],
|
|
638
|
+
platform: str,
|
|
639
|
+
out_dir: str,
|
|
640
|
+
workers: int,
|
|
641
|
+
redis: bool,
|
|
642
|
+
) -> None:
|
|
643
|
+
"""Generate deployment configuration for your PyWire app."""
|
|
644
|
+
from pywire_cli.deploy import (
|
|
645
|
+
generate_dockerfile,
|
|
646
|
+
generate_fly_toml,
|
|
647
|
+
generate_railway_json,
|
|
648
|
+
generate_render_yaml,
|
|
649
|
+
validate_deploy_config,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
project_root = Path(os.getcwd())
|
|
653
|
+
out_path = Path(out_dir)
|
|
654
|
+
|
|
655
|
+
# Auto-discover and verify app
|
|
656
|
+
if not app:
|
|
657
|
+
app = _discover_app_str()
|
|
658
|
+
console.print(f"📦 Preparing deploy config for [cyan]{app}[/]...")
|
|
659
|
+
|
|
660
|
+
# Pre-compile
|
|
661
|
+
app_instance = import_app(app)
|
|
662
|
+
|
|
663
|
+
pages_dir = Path(getattr(app_instance, "pages_dir", "pages"))
|
|
664
|
+
|
|
665
|
+
from pywire.compiler.build import build_project
|
|
666
|
+
|
|
667
|
+
build_project(pages_dir=pages_dir, out_dir=Path(".pywire/build"))
|
|
668
|
+
console.print("✅ Build complete")
|
|
669
|
+
|
|
670
|
+
# Validate
|
|
671
|
+
issues = validate_deploy_config(platform, project_root)
|
|
672
|
+
if issues:
|
|
673
|
+
for issue in issues:
|
|
674
|
+
console.print(f"⚠️ {issue}")
|
|
675
|
+
|
|
676
|
+
# Derive project name from directory
|
|
677
|
+
project_name = project_root.name
|
|
678
|
+
|
|
679
|
+
# Cloudflare Workers requires a paid plan
|
|
680
|
+
if platform == "cloudflare":
|
|
681
|
+
console.print(
|
|
682
|
+
"\n[bold yellow]Note:[/] Cloudflare Python Workers requires a "
|
|
683
|
+
"[bold]Workers Paid plan[/] ($5/month).\n"
|
|
684
|
+
" The free plan's size and startup limits are incompatible with "
|
|
685
|
+
"Python frameworks.\n"
|
|
686
|
+
" [link=https://dash.cloudflare.com/workers/plans]"
|
|
687
|
+
"https://dash.cloudflare.com/workers/plans[/link]\n"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Cloudflare uses Durable Objects — workers/redis flags don't apply
|
|
691
|
+
if platform == "cloudflare" and (workers > 1 or redis):
|
|
692
|
+
console.print(
|
|
693
|
+
"[bold red]Error:[/] [cyan]--workers[/] and [cyan]--redis[/] are not applicable "
|
|
694
|
+
"to Cloudflare Workers.\n"
|
|
695
|
+
" Cloudflare uses Durable Objects for session state — no Redis or worker "
|
|
696
|
+
"processes needed."
|
|
697
|
+
)
|
|
698
|
+
raise SystemExit(1)
|
|
699
|
+
|
|
700
|
+
# Warn about workers vs redis (not applicable to Cloudflare)
|
|
701
|
+
if platform != "cloudflare":
|
|
702
|
+
if workers > 1 and not redis:
|
|
703
|
+
console.print(
|
|
704
|
+
"\n[bold yellow]⚠️ Warning:[/] Running multiple workers without Redis "
|
|
705
|
+
"will break session state.\n"
|
|
706
|
+
" Add [cyan]--redis[/] or set [cyan]REDIS_URL[/] at runtime.\n"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
if redis:
|
|
710
|
+
console.print(
|
|
711
|
+
"\n[bold yellow]⚠️ Note:[/] Adding a Redis/Valkey store will increase "
|
|
712
|
+
"resource usage and may\n"
|
|
713
|
+
" incur additional costs depending on your hosting provider.\n"
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Generate config files
|
|
717
|
+
files_to_write: list[tuple[str, str]] = []
|
|
718
|
+
|
|
719
|
+
if platform == "docker":
|
|
720
|
+
files_to_write.append(
|
|
721
|
+
("Dockerfile", generate_dockerfile(project_root, workers=workers))
|
|
722
|
+
)
|
|
723
|
+
elif platform == "render":
|
|
724
|
+
files_to_write.append(
|
|
725
|
+
(
|
|
726
|
+
"render.yaml",
|
|
727
|
+
generate_render_yaml(project_root, project_name, redis=redis),
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
# Render uses Docker — always include Dockerfile so workers changes are picked up
|
|
731
|
+
files_to_write.append(
|
|
732
|
+
("Dockerfile", generate_dockerfile(project_root, workers=workers))
|
|
733
|
+
)
|
|
734
|
+
elif platform == "fly":
|
|
735
|
+
files_to_write.append(
|
|
736
|
+
("fly.toml", generate_fly_toml(project_root, project_name))
|
|
737
|
+
)
|
|
738
|
+
# Fly.io uses Docker — always include Dockerfile so workers changes are picked up
|
|
739
|
+
files_to_write.append(
|
|
740
|
+
("Dockerfile", generate_dockerfile(project_root, workers=workers))
|
|
741
|
+
)
|
|
742
|
+
elif platform == "railway":
|
|
743
|
+
files_to_write.append(("railway.json", generate_railway_json(project_root)))
|
|
744
|
+
# Always include Dockerfile so workers changes are picked up
|
|
745
|
+
files_to_write.append(
|
|
746
|
+
("Dockerfile", generate_dockerfile(project_root, workers=workers))
|
|
747
|
+
)
|
|
748
|
+
elif platform == "cloudflare":
|
|
749
|
+
from pywire_cli.deploy import (
|
|
750
|
+
generate_wrangler_toml,
|
|
751
|
+
generate_cf_entry,
|
|
752
|
+
generate_cf_durable_object,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
files_to_write.append(
|
|
756
|
+
("wrangler.toml", generate_wrangler_toml(project_root, project_name))
|
|
757
|
+
)
|
|
758
|
+
files_to_write.append(
|
|
759
|
+
("entry.py", generate_cf_entry(project_root, app_string=app))
|
|
760
|
+
)
|
|
761
|
+
files_to_write.append(
|
|
762
|
+
("pywire_do.py", generate_cf_durable_object(project_root, app_string=app))
|
|
763
|
+
)
|
|
764
|
+
# Exclude local .venv from CF bundle to avoid duplicate packages
|
|
765
|
+
files_to_write.append(
|
|
766
|
+
(
|
|
767
|
+
".wranglerignore",
|
|
768
|
+
".venv/\n.git/\n__pycache__/\n.pywire/build/\n",
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
raise click.UsageError(f"Unknown platform: {platform}")
|
|
773
|
+
|
|
774
|
+
for filename, content in files_to_write:
|
|
775
|
+
target = out_path / filename
|
|
776
|
+
if target.exists():
|
|
777
|
+
if not click.confirm(f"'{target}' already exists. Overwrite?"):
|
|
778
|
+
console.print(f"Skipped [cyan]{target}[/]")
|
|
779
|
+
_print_skip_hint(filename, platform, workers, redis, project_name)
|
|
780
|
+
continue
|
|
781
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
782
|
+
target.write_text(content)
|
|
783
|
+
console.print(f"✅ Generated [cyan]{target}[/]")
|
|
784
|
+
|
|
785
|
+
# Next steps guidance
|
|
786
|
+
redis_hint = (
|
|
787
|
+
"\n[bold]Scaling with Redis/Valkey:[/]\n"
|
|
788
|
+
f" The Dockerfile runs with [cyan]--workers {workers}[/].\n"
|
|
789
|
+
" To scale, install [cyan]pywire[redis][/] and set [cyan]REDIS_URL[/] —\n"
|
|
790
|
+
" PyWire auto-detects it for shared session state (no code changes needed)."
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
if platform == "docker":
|
|
794
|
+
console.print(
|
|
795
|
+
"\n[bold]Next steps:[/]\n"
|
|
796
|
+
f" 1. [cyan]docker build -t {project_name} .[/]\n"
|
|
797
|
+
f" 2. [cyan]docker run -p 8000:8000 {project_name}[/]"
|
|
798
|
+
)
|
|
799
|
+
if not redis:
|
|
800
|
+
console.print(
|
|
801
|
+
redis_hint + "\n"
|
|
802
|
+
f" [cyan]docker run -p 8000:8000 -e REDIS_URL=redis://your-redis:6379 {project_name}[/]"
|
|
803
|
+
)
|
|
804
|
+
else:
|
|
805
|
+
console.print(
|
|
806
|
+
"\n Redis is configured. Run with [cyan]REDIS_URL[/]:\n"
|
|
807
|
+
f" [cyan]docker run -p 8000:8000 -e REDIS_URL=redis://your-redis:6379 {project_name}[/]"
|
|
808
|
+
)
|
|
809
|
+
elif platform == "render":
|
|
810
|
+
console.print(
|
|
811
|
+
"\n[bold]Next steps:[/]\n"
|
|
812
|
+
" 1. Push your code to a Git repository\n"
|
|
813
|
+
" 2. Go to [link=https://dashboard.render.com]dashboard.render.com[/link] "
|
|
814
|
+
"→ [bold]New → Blueprint[/] and connect your repo\n"
|
|
815
|
+
" 3. Render reads [cyan]render.yaml[/] automatically and provisions the service"
|
|
816
|
+
)
|
|
817
|
+
if redis:
|
|
818
|
+
console.print(
|
|
819
|
+
"\n Redis KV store is included in [cyan]render.yaml[/]. Render will provision\n"
|
|
820
|
+
" it and inject [cyan]REDIS_URL[/] automatically."
|
|
821
|
+
)
|
|
822
|
+
else:
|
|
823
|
+
console.print(
|
|
824
|
+
redis_hint + "\n"
|
|
825
|
+
" Use [cyan]pywire deploy --platform render --redis[/] to generate a\n"
|
|
826
|
+
" [cyan]render.yaml[/] with a KV store pre-configured."
|
|
827
|
+
)
|
|
828
|
+
elif platform == "fly":
|
|
829
|
+
console.print(
|
|
830
|
+
"\n[bold]Next steps:[/]\n"
|
|
831
|
+
" 1. Install the Fly CLI: [cyan]curl -L https://fly.io/install.sh | sh[/]\n"
|
|
832
|
+
" 2. Run [cyan]fly launch --no-deploy[/] to import [cyan]fly.toml[/]\n"
|
|
833
|
+
" 3. Deploy with [cyan]fly deploy[/]\n"
|
|
834
|
+
"\n[bold]Scaling:[/]\n"
|
|
835
|
+
f" The Dockerfile runs with [cyan]--workers {workers}[/].\n"
|
|
836
|
+
" To scale to multiple machines ([cyan]fly scale count N[/]):\n"
|
|
837
|
+
" • [bold]Option A — Fly sticky sessions:[/] Route sessions to the same machine\n"
|
|
838
|
+
" using [cyan]fly-replay[/]. Simple but breaks for VPNs/corporate proxies.\n"
|
|
839
|
+
" • [bold]Option B — Redis/Valkey (recommended):[/] Add Upstash Redis via\n"
|
|
840
|
+
" [cyan]fly redis create[/], set [cyan]REDIS_URL[/], and install\n"
|
|
841
|
+
" [cyan]pywire[redis][/]. PyWire auto-detects it — no code changes needed."
|
|
842
|
+
)
|
|
843
|
+
elif platform == "railway":
|
|
844
|
+
console.print(
|
|
845
|
+
"\n[bold]Next steps:[/]\n"
|
|
846
|
+
" 1. Install the Railway CLI: [cyan]npm i -g @railway/cli[/]\n"
|
|
847
|
+
" 2. Run [cyan]railway login[/] and [cyan]railway init[/]\n"
|
|
848
|
+
" 3. Run [cyan]railway link[/] to connect to your Railway project\n"
|
|
849
|
+
" 4. Deploy with [cyan]railway up[/]\n"
|
|
850
|
+
"\n Railway auto-detects the Dockerfile and builds your app."
|
|
851
|
+
)
|
|
852
|
+
if redis or workers > 1:
|
|
853
|
+
console.print(
|
|
854
|
+
redis_hint + "\n"
|
|
855
|
+
" [bold]Note:[/] Redis cannot be provisioned via [cyan]railway.json[/].\n"
|
|
856
|
+
" Add it with [cyan]railway add[/] (select Redis/Valkey) or via the\n"
|
|
857
|
+
" Railway dashboard. Railway injects [cyan]REDIS_URL[/] automatically.\n"
|
|
858
|
+
" Then install the Redis extra: [cyan]uv add pywire[redis][/]"
|
|
859
|
+
)
|
|
860
|
+
else:
|
|
861
|
+
console.print(
|
|
862
|
+
redis_hint + "\n"
|
|
863
|
+
" Add a Redis addon via [cyan]railway add[/] or the Railway dashboard.\n"
|
|
864
|
+
" Railway injects [cyan]REDIS_URL[/] automatically.\n"
|
|
865
|
+
" Then install the Redis extra: [cyan]uv add pywire[redis][/]"
|
|
866
|
+
)
|
|
867
|
+
elif platform == "cloudflare":
|
|
868
|
+
console.print(
|
|
869
|
+
"\n[bold]Local development:[/]\n"
|
|
870
|
+
" • [bold]Fast mode[/] (standard hot-reload, no build step needed):\n"
|
|
871
|
+
" [cyan]uv run pywire dev[/]\n"
|
|
872
|
+
" • [bold]Workers mode[/] (runs in local workerd — matches CF production):\n"
|
|
873
|
+
" [cyan]uv run pywire build --platform cloudflare[/]\n"
|
|
874
|
+
" [cyan]uv run pywrangler dev[/]\n"
|
|
875
|
+
"\n[bold]Deploy to Cloudflare:[/]\n"
|
|
876
|
+
" 1. Add workers-py if not present: [cyan]uv add --dev workers-py[/]\n"
|
|
877
|
+
" 2. Build: [cyan]uv run pywire build --platform cloudflare[/]\n"
|
|
878
|
+
" 3. Deploy: [cyan]uv run pywrangler deploy[/]\n"
|
|
879
|
+
"\n[bold]Generated files:[/]\n"
|
|
880
|
+
" • [cyan]wrangler.toml[/] — Cloudflare config with Durable Objects binding\n"
|
|
881
|
+
" • [cyan]entry.py[/] — Workers entry point (routes WS to Durable Objects)\n"
|
|
882
|
+
" • [cyan]pywire_do.py[/] — Durable Object for session + WebSocket handling\n"
|
|
883
|
+
"\n[bold]Architecture:[/]\n"
|
|
884
|
+
" Each session runs in a Durable Object with persistent storage and\n"
|
|
885
|
+
" WebSocket support. Real-time reactivity works out of the box.\n"
|
|
886
|
+
" No Redis or worker processes needed — Durable Objects handle state.\n"
|
|
887
|
+
"\n[bold]CI/CD:[/]\n"
|
|
888
|
+
" [cyan]uv sync && uv run pywire build --platform cloudflare "
|
|
889
|
+
"&& uv run pywrangler deploy[/]"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
if __name__ == "__main__":
|
|
894
|
+
cli()
|