Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4f77ba93 | ||
|
|
66c2a9bf07 |
@@ -8,11 +8,11 @@
|
|||||||
* - Disables Vite's screen clearing on startup
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ['/api'])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
|
const backendUrl = process.env.PASKIA_BACKEND_URL || 'http://localhost:4402'
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-paskia",
|
name: 'vite-plugin-fastapi-paskia',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../paskia/frontend-build",
|
outDir: '../paskia/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
+14
-11
@@ -5,8 +5,8 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
from fastapi_vue import env, server, teleport
|
||||||
from fastapi_vue import server
|
from fastapi_vue.logging import setup_logging
|
||||||
from kanta import Kanta
|
from kanta import Kanta
|
||||||
|
|
||||||
from paskia.db import legacy
|
from paskia.db import legacy
|
||||||
@@ -17,8 +17,12 @@ from paskia.domains import build as build_registry
|
|||||||
from paskia.domains import configure as configure_domains
|
from paskia.domains import configure as configure_domains
|
||||||
from paskia.domains import validate_config
|
from paskia.domains import validate_config
|
||||||
from paskia.util import hostutil, startupbox
|
from paskia.util import hostutil, startupbox
|
||||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
from paskia.util.runtime import serve_config
|
||||||
from paskia.util.runtime import ServeConfig
|
|
||||||
|
# Keep the literal value here: fastapi-vue-setup reads DEFAULT_PORT from
|
||||||
|
# this module on upgrades. The app-side shared copy is paskia.util.constants.
|
||||||
|
DEFAULT_PORT = 4401
|
||||||
|
os.environ["FASTAPI_VUE"] = "PASKIA"
|
||||||
|
|
||||||
EPILOG = """\
|
EPILOG = """\
|
||||||
Examples:
|
Examples:
|
||||||
@@ -226,11 +230,10 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
|||||||
# is the admin's job via the admin interface) are logged by build().
|
# is the admin's job via the admin interface) are logged by build().
|
||||||
|
|
||||||
# Pass process-global serve parameters to the server process(es)
|
# Pass process-global serve parameters to the server process(es)
|
||||||
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(
|
serve_config().listen = listen
|
||||||
ServeConfig(listen=listen)
|
teleport() # Serialize bound config before spawning workers
|
||||||
).decode()
|
|
||||||
|
|
||||||
startupbox.print_startup_config(registry, listen=listen)
|
startupbox.print_startup_config(registry, listen=listen, default_port=DEFAULT_PORT)
|
||||||
|
|
||||||
# Run the server (spawns processes in dev mode)
|
# Run the server (spawns processes in dev mode)
|
||||||
# tracerite, access logging and log config are handled by fastapi_vue.server;
|
# tracerite, access logging and log config are handled by fastapi_vue.server;
|
||||||
@@ -241,13 +244,13 @@ def cmd_serve(args: argparse.Namespace) -> None:
|
|||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
server_header=False,
|
server_header=False,
|
||||||
startup_box=None,
|
startup_box=None,
|
||||||
reload=Path(__file__).parent if DEVMODE else False,
|
reload=Path(__file__).parent if env.dev else False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Configure logging to remove the "ERROR:root:" prefix
|
# Full logging setup (tracerite, formatting) before any CLI output
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
|
setup_logging()
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="paskia",
|
prog="paskia",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from kanta.logging import configure_logging as configure_kanta_logging
|
from fastapi_vue import env
|
||||||
|
|
||||||
from paskia import authcode, db, domains, remoteauth
|
from paskia import authcode, db, domains, remoteauth
|
||||||
from paskia.bootstrap import bootstrap_if_needed
|
from paskia.bootstrap import bootstrap_if_needed
|
||||||
@@ -19,12 +19,8 @@ from paskia.fastapi.dispatch import DispatchMiddleware
|
|||||||
from paskia.fastapi.front import frontend
|
from paskia.fastapi.front import frontend
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import passphrase, vitedev
|
from paskia.util import passphrase, vitedev
|
||||||
from paskia.util.constants import DEVMODE
|
|
||||||
from paskia.util.runtime import serve_config
|
from paskia.util.runtime import serve_config
|
||||||
|
|
||||||
# Configure custom logging
|
|
||||||
configure_kanta_logging()
|
|
||||||
|
|
||||||
# Path to examples/index.html when running from source tree
|
# Path to examples/index.html when running from source tree
|
||||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
Domain configuration is read from the database.
|
Domain configuration is read from the database.
|
||||||
"""
|
"""
|
||||||
cfg = serve_config()
|
cfg = serve_config()
|
||||||
domains.configure(listen=cfg.listen if cfg else None)
|
domains.configure(listen=cfg.listen)
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
|
||||||
@@ -68,7 +64,7 @@ app = FastAPI(
|
|||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
debug=DEVMODE,
|
debug=env.dev,
|
||||||
)
|
)
|
||||||
|
|
||||||
# WebSocket and HTTP access logging is handled by fastapi_vue's ASGI middleware;
|
# WebSocket and HTTP access logging is handled by fastapi_vue's ASGI middleware;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Small, dependency-free constants shared by CLI and server modules."""
|
"""Small, dependency-free constants shared by CLI and server modules."""
|
||||||
|
|
||||||
import os
|
# App-side default; paskia.__main__ keeps its own literal copy because
|
||||||
|
# fastapi-vue-setup reads DEFAULT_PORT from the CLI module on upgrades.
|
||||||
DEFAULT_PORT = 4401
|
DEFAULT_PORT = 4401
|
||||||
DEVMODE = os.getenv("PASKIA_DEV") == "1"
|
|
||||||
|
|||||||
@@ -98,15 +98,3 @@ def normalize_host(raw_host: str | None) -> str | None:
|
|||||||
# Strip port from host:port
|
# Strip port from host:port
|
||||||
netloc = netloc.rsplit(":", 1)[0]
|
netloc = netloc.rsplit(":", 1)[0]
|
||||||
return netloc.lower().rstrip(".") or None
|
return netloc.lower().rstrip(".") or None
|
||||||
|
|
||||||
|
|
||||||
def format_endpoint(ep: dict) -> str:
|
|
||||||
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
|
|
||||||
if uds := ep.get("uds"):
|
|
||||||
return f"unix:{uds}"
|
|
||||||
host = ep["host"]
|
|
||||||
port = ep["port"]
|
|
||||||
# Bracket IPv6 addresses
|
|
||||||
if ":" in host:
|
|
||||||
host = f"[{host}]"
|
|
||||||
return f"{host}:{port}"
|
|
||||||
|
|||||||
+9
-18
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
Domain configuration lives in the database (``Config.domains``); the
|
Domain configuration lives in the database (``Config.domains``); the
|
||||||
``PASKIA_CONFIG`` environment variable only carries the effective listen
|
``PASKIA_CONFIG`` environment variable only carries the effective listen
|
||||||
endpoints so that child processes (uvicorn reload / workers) can derive
|
endpoints so that child processes (uvicorn reload / workers) derive site
|
||||||
site URLs the same way the parent did.
|
URLs the same way the parent did. The CLI entry point mutates the bound
|
||||||
|
object before ``server.run()`` calls ``teleport()`` to pass it on.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from fastapi_vue import env
|
||||||
|
|
||||||
|
|
||||||
class ServeConfig(msgspec.Struct):
|
class ServeConfig(msgspec.Struct):
|
||||||
@@ -18,19 +17,11 @@ class ServeConfig(msgspec.Struct):
|
|||||||
listen: list[str] | None = None
|
listen: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
def serve_config() -> ServeConfig:
|
||||||
def _load() -> ServeConfig | None:
|
"""Return the serve configuration bound to PASKIA_CONFIG."""
|
||||||
raw = os.getenv("PASKIA_CONFIG")
|
return env(ServeConfig, name="CONFIG")
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
return msgspec.json.decode(raw.encode(), type=ServeConfig)
|
|
||||||
|
|
||||||
|
|
||||||
def serve_config() -> ServeConfig | None:
|
|
||||||
"""Return cached serve configuration loaded from PASKIA_CONFIG."""
|
|
||||||
return _load()
|
|
||||||
|
|
||||||
|
|
||||||
def clear_cache() -> None:
|
def clear_cache() -> None:
|
||||||
"""Clear cached serve configuration; next serve_config() reloads."""
|
"""Drop the bound configuration; next serve_config() re-decodes."""
|
||||||
_load.cache_clear()
|
env._bindings.pop("CONFIG", None) # noqa: SLF001
|
||||||
|
|||||||
+24
-59
@@ -2,24 +2,21 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from sys import stderr
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi_vue import env
|
||||||
from fastapi_vue.hostutil import parse_endpoints
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
from fastapi_vue.server import print_startup_box
|
||||||
|
|
||||||
from paskia._version import __version__
|
|
||||||
from paskia.domains import auth_host_url, origin_url, partition_origins
|
from paskia.domains import auth_host_url, origin_url, partition_origins
|
||||||
from paskia.util import hostutil
|
from paskia.util import hostutil
|
||||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
from paskia.util.hostutil import wildcard_base
|
||||||
from paskia.util.hostutil import format_endpoint, wildcard_base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from paskia.domains import DomainRegistry
|
from paskia.domains import DomainRegistry
|
||||||
|
|
||||||
BOX_WIDTH = 80 # Maximum inner width (excluding box chars)
|
|
||||||
URL_COL = 22 # Column where header URLs start (past the logo graphic)
|
URL_COL = 22 # Column where header URLs start (past the logo graphic)
|
||||||
|
|
||||||
# ANSI color codes
|
# ANSI color codes
|
||||||
@@ -28,46 +25,12 @@ YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
|
|||||||
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
|
||||||
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
|
||||||
|
|
||||||
_TOKENS = re.compile(r"\033\[[0-9;]*m|.")
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_len(text: str) -> int:
|
def _visible_len(text: str) -> int:
|
||||||
"""Calculate visible length of text, ignoring ANSI escape codes."""
|
"""Calculate visible length of text, ignoring ANSI escape codes."""
|
||||||
return len(re.sub(r"\033\[[0-9;]*m", "", text))
|
return len(re.sub(r"\033\[[0-9;]*m", "", text))
|
||||||
|
|
||||||
|
|
||||||
def _truncate(text: str, width: int) -> str:
|
|
||||||
"""Cut text to at most `width` visible chars, keeping ANSI codes intact."""
|
|
||||||
if _visible_len(text) <= width:
|
|
||||||
return text
|
|
||||||
out = []
|
|
||||||
visible = 0
|
|
||||||
for tok in _TOKENS.findall(text):
|
|
||||||
if tok.startswith("\033"):
|
|
||||||
out.append(tok)
|
|
||||||
elif visible < width - 1:
|
|
||||||
out.append(tok)
|
|
||||||
visible += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return "".join(out) + "…" + RESET
|
|
||||||
|
|
||||||
|
|
||||||
def line(text: str = "", width: int = BOX_WIDTH) -> str:
|
|
||||||
"""Format a line inside the box with proper padding, truncating if needed."""
|
|
||||||
text = _truncate(text, width)
|
|
||||||
padding = width - _visible_len(text)
|
|
||||||
return f"┃ {text}{' ' * padding} ┃\n"
|
|
||||||
|
|
||||||
|
|
||||||
def top(width: int = BOX_WIDTH) -> str:
|
|
||||||
return "┏" + "━" * (width + 2) + "┓\n"
|
|
||||||
|
|
||||||
|
|
||||||
def bottom(width: int = BOX_WIDTH) -> str:
|
|
||||||
return "┗" + "━" * (width + 2) + "┛\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _compact_url(url: str) -> str:
|
def _compact_url(url: str) -> str:
|
||||||
"""Bare host for https URLs; scheme and port kept for plain http."""
|
"""Bare host for https URLs; scheme and port kept for plain http."""
|
||||||
stripped = url.removeprefix("https://")
|
stripped = url.removeprefix("https://")
|
||||||
@@ -135,7 +98,10 @@ def _signin_summary(in_domain: list[str], rp_id: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def print_startup_config(
|
def print_startup_config(
|
||||||
registry: DomainRegistry, listen: list[str] | None = None
|
registry: DomainRegistry,
|
||||||
|
listen: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
default_port: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Print server configuration on startup (one section per domain)."""
|
"""Print server configuration on startup (one section per domain)."""
|
||||||
# Key graphic with yellow shading (bright for highlights, dark for body)
|
# Key graphic with yellow shading (bright for highlights, dark for body)
|
||||||
@@ -146,18 +112,15 @@ def print_startup_config(
|
|||||||
|
|
||||||
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
domains = sorted(registry.domains, key=lambda d: d.rp_id)
|
||||||
|
|
||||||
# Format listen endpoints (dev mode only uses the first endpoint)
|
# Endpoints as bound, passed to fastapi_vue for the {listen} field
|
||||||
endpoints = list(parse_endpoints(listen, DEFAULT_PORT))
|
endpoints = list(parse_endpoints(listen, default_port))
|
||||||
if DEVMODE:
|
|
||||||
endpoints = endpoints[:1] # server.run reload=True uses only one
|
|
||||||
parts = [format_endpoint(ep) for ep in endpoints]
|
|
||||||
|
|
||||||
# Header URLs: when a vite dev server is configured, its URL (marked
|
# Header URLs: when a vite dev server is configured, its URL (marked
|
||||||
# "vite dev"); otherwise one per configured auth host (a full origin URL,
|
# "vite dev"); otherwise one per configured auth host (a full origin URL,
|
||||||
# clickable in terminals). If none are configured, guess one domain
|
# clickable in terminals). If none are configured, guess one domain
|
||||||
# (prefer the shortest https rp_id) and link its /auth/ site path.
|
# (prefer the shortest https rp_id) and link its /auth/ site path.
|
||||||
# Entries are pre-styled: bold for the URL, plain for any marker.
|
# Entries are pre-styled: bold for the URL, plain for any marker.
|
||||||
vite_url = os.environ.get("PASKIA_VITE_URL") if DEVMODE else None
|
vite_url = env.vite_url if env.dev else None
|
||||||
if vite_url:
|
if vite_url:
|
||||||
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
|
header_urls = [f"{w}{vite_url}{r} (vite dev)"]
|
||||||
else:
|
else:
|
||||||
@@ -178,9 +141,10 @@ def print_startup_config(
|
|||||||
rows = []
|
rows = []
|
||||||
# Logo lines 4-5 carry the first two header URLs; further URLs go on
|
# Logo lines 4-5 carry the first two header URLs; further URLs go on
|
||||||
# blank-gutter lines beneath the graphic, all at the same column.
|
# blank-gutter lines beneath the graphic, all at the same column.
|
||||||
|
# @VERSION@/@LISTEN@ are filled in by fastapi_vue's print_startup_box.
|
||||||
logo = [
|
logo = [
|
||||||
f" {b}▄▄▄▄▄{r}",
|
f" {b}▄▄▄▄▄{r}",
|
||||||
f"{b}█{y} {b}█{r} Paskia {__version__} @ {' '.join(parts)}",
|
f"{b}█{y} {b}█{r} Paskia @VERSION@ @ @LISTEN@",
|
||||||
f"{b}█{y} {b}█{y}▄▄▄▄▄▄▄▄▄▄▄▄{r}",
|
f"{b}█{y} {b}█{y}▄▄▄▄▄▄▄▄▄▄▄▄{r}",
|
||||||
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r}",
|
f"{b}█{y} {b}█{y}▀▀▀▀{b}█{y}▀▀{b}█{y}▀▀{b}█{r}",
|
||||||
f" {y}▀▀▀▀▀{r}",
|
f" {y}▀▀▀▀▀{r}",
|
||||||
@@ -196,7 +160,7 @@ def print_startup_config(
|
|||||||
rows.append(f"{' ' * URL_COL}{url}")
|
rows.append(f"{' ' * URL_COL}{url}")
|
||||||
|
|
||||||
for domain in domains:
|
for domain in domains:
|
||||||
# One compact line per domain; overlong lines are capped at render.
|
# One compact line per domain.
|
||||||
rp_name = domain.rp_name
|
rp_name = domain.rp_name
|
||||||
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
suffix = f" ({rp_name})" if rp_name and rp_name != domain.rp_id else ""
|
||||||
head = f"{w}{domain.rp_id}{r}{suffix}"
|
head = f"{w}{domain.rp_id}{r}{suffix}"
|
||||||
@@ -204,21 +168,22 @@ def print_startup_config(
|
|||||||
rows.append(f"{head} — no sign-in sites")
|
rows.append(f"{head} — no sign-in sites")
|
||||||
continue
|
continue
|
||||||
in_domain, related = partition_origins(domain.rp_id, domain.config.origins)
|
in_domain, related = partition_origins(domain.rp_id, domain.config.origins)
|
||||||
parts = []
|
phrases = []
|
||||||
if in_domain:
|
if in_domain:
|
||||||
parts.append(_signin_summary(in_domain, domain.rp_id))
|
phrases.append(_signin_summary(in_domain, domain.rp_id))
|
||||||
parts.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
phrases.extend(_compact_url(origin_url(k)) for k in sorted(related))
|
||||||
# "with" implies the rp_id itself may sign in (exact key or a full
|
# "with" implies the rp_id itself may sign in (exact key or a full
|
||||||
# wildcard); otherwise the origins are a mere list, after a colon.
|
# wildcard); otherwise the origins are a mere list, after a colon.
|
||||||
covers_self = any(
|
covers_self = any(
|
||||||
k == domain.rp_id or k == f"**.{domain.rp_id}" for k in in_domain
|
k == domain.rp_id or k == f"**.{domain.rp_id}" for k in in_domain
|
||||||
)
|
)
|
||||||
sep = " with " if covers_self else ": "
|
sep = " with " if covers_self else ": "
|
||||||
rows.append(f"{head}{sep}{' and '.join(parts)}")
|
rows.append(f"{head}{sep}{' and '.join(phrases)}")
|
||||||
|
|
||||||
# Size the box to the widest row, capped at BOX_WIDTH.
|
# fastapi_vue prints the box: version from package metadata, listen
|
||||||
width = min(BOX_WIDTH, max(_visible_len(t) for t in rows))
|
# addresses as bound (localhost expanded to both loopbacks). Braces in
|
||||||
out = [top(width)]
|
# our content (e.g. an rp-name) are escaped before template formatting.
|
||||||
out.extend(line(text, width) for text in rows)
|
text = "\n".join(rows)
|
||||||
out.append(bottom(width))
|
text = text.replace("{", "{{").replace("}", "}}")
|
||||||
stderr.write("".join(out))
|
text = text.replace("@VERSION@", "{version}").replace("@LISTEN@", "{listen}")
|
||||||
|
print_startup_box(text, "paskia.fastapi.mainapp:app", endpoints)
|
||||||
|
|||||||
+2
-2
@@ -21,8 +21,8 @@ dependencies = [
|
|||||||
"pyjwt[crypto]>=2.11.0",
|
"pyjwt[crypto]>=2.11.0",
|
||||||
"jsondiff>=2.2.1",
|
"jsondiff>=2.2.1",
|
||||||
"msgspec>=0.20.0",
|
"msgspec>=0.20.0",
|
||||||
"fastapi-vue~=1.4.2",
|
"fastapi-vue~=1.7.1",
|
||||||
"kanta>=0.7.0",
|
"kanta>=0.9.2",
|
||||||
"uarite>=0.2.1",
|
"uarite>=0.2.1",
|
||||||
]
|
]
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
+68
-58
@@ -10,6 +10,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import tracerite
|
import tracerite
|
||||||
@@ -68,10 +69,13 @@ def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
|
|||||||
return "\n".join(caddyfile_parts)
|
return "\n".join(caddyfile_parts)
|
||||||
|
|
||||||
|
|
||||||
async def run_caddy(
|
async def run_caddy(origins: list[str], viteurl: str, backurl: str) -> None:
|
||||||
origins: list[str], viteurl: str, backurl: str
|
"""Run Caddy as HTTPS reverse proxy for the group's lifetime.
|
||||||
) -> asyncio.subprocess.Process:
|
|
||||||
"""Start Caddy as HTTPS reverse proxy, wait for ready signal."""
|
Waits for the ready signal, then drains stderr until Caddy exits or the
|
||||||
|
task is cancelled (ProcessGroup shutdown), terminating Caddy on exit.
|
||||||
|
Raises CalledProcessError if Caddy dies, cancelling the group.
|
||||||
|
"""
|
||||||
caddy_path = shutil.which("caddy")
|
caddy_path = shutil.which("caddy")
|
||||||
if not caddy_path:
|
if not caddy_path:
|
||||||
logger.warning("Caddy not found. Install it to use --caddy option.")
|
logger.warning("Caddy not found. Install it to use --caddy option.")
|
||||||
@@ -86,57 +90,56 @@ async def run_caddy(
|
|||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
proc.stdin.write(caddyfile.encode())
|
try:
|
||||||
await proc.stdin.drain()
|
proc.stdin.write(caddyfile.encode())
|
||||||
proc.stdin.close()
|
await proc.stdin.drain()
|
||||||
|
proc.stdin.close()
|
||||||
|
|
||||||
# Wait for ready signal or failure
|
# Wait for ready signal or failure
|
||||||
while True:
|
|
||||||
if proc.returncode is not None:
|
|
||||||
remaining = await proc.stderr.read()
|
|
||||||
for line in remaining.decode().splitlines():
|
|
||||||
if line:
|
|
||||||
logger.info("caddy: %s", line)
|
|
||||||
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
line = await proc.stderr.readline()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
|
|
||||||
decoded = line.decode().rstrip()
|
|
||||||
if "serving initial configuration" in decoded:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Parse and show errors during startup
|
|
||||||
if decoded:
|
|
||||||
try:
|
|
||||||
log = json.loads(decoded)
|
|
||||||
level = log.get("level", "")
|
|
||||||
if level in ("error", "fatal", "warn"):
|
|
||||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
if "error" in decoded.lower() or "fatal" in decoded.lower():
|
|
||||||
logger.warning("caddy: %s", decoded)
|
|
||||||
|
|
||||||
# Start background task to drain stderr
|
|
||||||
async def drain_caddy_stderr():
|
|
||||||
while True:
|
while True:
|
||||||
|
if proc.returncode is not None:
|
||||||
|
await log_caddy_stderr(proc.stderr, starting=True)
|
||||||
|
logger.warning("Caddy startup failed (exit code %d)", proc.returncode)
|
||||||
|
raise CalledProcessError(proc.returncode, cmd)
|
||||||
|
|
||||||
line = await proc.stderr.readline()
|
line = await proc.stderr.readline()
|
||||||
if not line:
|
if not line:
|
||||||
break
|
continue
|
||||||
decoded = line.decode().rstrip()
|
|
||||||
if decoded:
|
|
||||||
try:
|
|
||||||
log = json.loads(decoded)
|
|
||||||
level = log.get("level", "")
|
|
||||||
if level in ("error", "fatal", "warn"):
|
|
||||||
logger.warning("caddy: %s", log.get("msg", decoded))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass # Ignore non-JSON output after startup
|
|
||||||
|
|
||||||
asyncio.create_task(drain_caddy_stderr())
|
decoded = line.decode().rstrip()
|
||||||
return proc
|
if "serving initial configuration" in decoded:
|
||||||
|
break
|
||||||
|
|
||||||
|
log_caddy_line(decoded, starting=True)
|
||||||
|
|
||||||
|
# Drain stderr until Caddy exits
|
||||||
|
await proc.wait()
|
||||||
|
await log_caddy_stderr(proc.stderr)
|
||||||
|
raise CalledProcessError(proc.returncode, cmd)
|
||||||
|
finally:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
proc.terminate()
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def log_caddy_line(decoded: str, *, starting: bool = False) -> None:
|
||||||
|
"""Log one Caddy stderr line (JSON during/after startup)."""
|
||||||
|
if not decoded:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log = json.loads(decoded)
|
||||||
|
level = log.get("level", "")
|
||||||
|
if level in ("error", "fatal", "warn"):
|
||||||
|
logger.warning("caddy: %s", log.get("msg", decoded))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
if starting and ("error" in decoded.lower() or "fatal" in decoded.lower()):
|
||||||
|
logger.warning("caddy: %s", decoded)
|
||||||
|
|
||||||
|
|
||||||
|
async def log_caddy_stderr(stream: asyncio.StreamReader, *, starting: bool = False) -> None:
|
||||||
|
"""Drain and log remaining Caddy stderr."""
|
||||||
|
while line := await stream.readline():
|
||||||
|
log_caddy_line(line.decode().rstrip(), starting=starting)
|
||||||
|
|
||||||
|
|
||||||
def _split_multi(values: list[str] | None) -> list[str]:
|
def _split_multi(values: list[str] | None) -> list[str]:
|
||||||
@@ -204,22 +207,25 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
|||||||
caddy_origins.append(f"https://{rp_id}")
|
caddy_origins.append(f"https://{rp_id}")
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||||
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
pg.create_task(run_caddy(caddy_origins, viteurl, backurl))
|
||||||
pg._procs.append(caddy_proc)
|
|
||||||
pg._cmds[caddy_proc.pid] = "caddy"
|
|
||||||
|
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
||||||
await check_ports_free(viteurl, backurl)
|
await pg.spawn(*paskia, vital=True)
|
||||||
await pg.spawn(*paskia)
|
|
||||||
await pg.wait(
|
await pg.wait(
|
||||||
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
|
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
|
||||||
)
|
)
|
||||||
await pg.spawn(*vite, cwd=frontend_path)
|
await pg.spawn(*vite, cwd=frontend_path, vital=True)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
tracerite.load()
|
tracerite.load()
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
parser = argparse.ArgumentParser(
|
||||||
|
add_help=False,
|
||||||
|
description="Run Vite and FastAPI development servers",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=HELP_EPILOG,
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-l",
|
"-l",
|
||||||
"--listen",
|
"--listen",
|
||||||
@@ -243,8 +249,12 @@ def main():
|
|||||||
)
|
)
|
||||||
args, remaining = parser.parse_known_args()
|
args, remaining = parser.parse_known_args()
|
||||||
|
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args, remaining))
|
asyncio.run(run_devserver(args, remaining))
|
||||||
|
except* KeyboardInterrupt:
|
||||||
|
pass # user stopped the devserver: normal exit
|
||||||
|
except* subprocess.SubprocessError, RuntimeError:
|
||||||
|
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
|
|||||||
@@ -10,21 +10,31 @@ from pathlib import Path
|
|||||||
|
|
||||||
MIN_NODE_VERSION = 20
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
# Duplicated from fastapi_vue.logging because build environment is isolated
|
||||||
|
_LEVEL_EMOJI = {
|
||||||
|
logging.DEBUG: "🐛",
|
||||||
|
logging.INFO: "🔷",
|
||||||
|
logging.WARNING: "❗",
|
||||||
|
logging.ERROR: "🛑",
|
||||||
|
logging.CRITICAL: "🚨",
|
||||||
|
}
|
||||||
|
|
||||||
class _PrefixFormatter(logging.Formatter):
|
|
||||||
"""Formatter that adds prefix based on log level."""
|
class _Formatter(logging.Formatter):
|
||||||
|
"""Emoji level prefix formatter, mirroring fastapi_vue.logging.Formatter."""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
if record.levelno >= logging.WARNING:
|
emoji = _LEVEL_EMOJI.get(record.levelno)
|
||||||
return f"⚠️ {record.getMessage()}"
|
prefix = f"{emoji} " if emoji else f"{record.levelname}: "
|
||||||
return record.getMessage()
|
return prefix + record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
_handler.setFormatter(_PrefixFormatter())
|
_handler.setFormatter(_Formatter())
|
||||||
logger = logging.getLogger("fastapi-vue")
|
logger = logging.getLogger("fastapi-vue")
|
||||||
logger.addHandler(_handler)
|
logger.addHandler(_handler)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False # own handler; do not double-print via a configured root
|
||||||
|
|
||||||
|
|
||||||
def _check_node_version(node_path: str) -> None:
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
|||||||
@@ -1,108 +1,87 @@
|
|||||||
# ruff: noqa: INP001
|
# ruff: noqa: INP001
|
||||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
|
from asyncio.subprocess import Process
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
from subprocess import CalledProcessError
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup(asyncio.TaskGroup):
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""TaskGroup with structured ownership of async subprocesses."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||||
"""Initialize empty process tracking."""
|
"""Set the grace period before terminate() escalates to kill()."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
super().__init__()
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
self._terminate_timeout = terminate_timeout
|
||||||
|
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||||
*cmd: str,
|
) -> Process:
|
||||||
cwd: str | None = None,
|
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||||
) -> asyncio.subprocess.Process:
|
|
||||||
"""Spawn a subprocess and track it."""
|
|
||||||
cmd_name = Path(cmd[0]).stem
|
|
||||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
|
||||||
self._procs.append(proc)
|
|
||||||
self._cmds[proc.pid] = cmd_name
|
|
||||||
return proc
|
|
||||||
|
|
||||||
async def wait(
|
async def run() -> None:
|
||||||
self,
|
name = Path(cmd[0]).stem
|
||||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||||
) -> None:
|
try:
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._cmds[proc] = cmd
|
||||||
|
started.set_result(proc)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
started.set_exception(e)
|
||||||
|
return
|
||||||
|
|
||||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
try:
|
||||||
returncode = await proc.wait()
|
returncode = await proc.wait()
|
||||||
if returncode != 0:
|
finally:
|
||||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
|
||||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
|
||||||
for w in waitables
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
|
||||||
raise SystemExit(1) from None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
"""Enter the async context manager."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
|
||||||
await self._cleanup(immediate=exc_type is not None)
|
|
||||||
|
|
||||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if not running:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not immediate:
|
|
||||||
# Wait for any one process to exit
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await asyncio.wait(
|
|
||||||
[asyncio.create_task(p.wait()) for p in running],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Terminate remaining processes
|
|
||||||
for p in self._procs:
|
|
||||||
if p.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
p.terminate()
|
proc.terminate()
|
||||||
|
|
||||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
|
||||||
still_running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if still_running:
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(
|
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||||
asyncio.wait_for(
|
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
|
||||||
timeout=10,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
for p in self._procs:
|
with suppress(ProcessLookupError):
|
||||||
if p.returncode is None:
|
proc.kill()
|
||||||
with suppress(ProcessLookupError):
|
await proc.wait()
|
||||||
p.kill()
|
|
||||||
await p.wait()
|
if vital:
|
||||||
|
logger.warning("Vital process %s exited", name)
|
||||||
|
raise CalledProcessError(returncode, cmd)
|
||||||
|
|
||||||
|
started = asyncio.get_running_loop().create_future()
|
||||||
|
self.create_task(run())
|
||||||
|
return await asyncio.shield(started)
|
||||||
|
|
||||||
|
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||||
|
"""Wait concurrently and return results in argument order."""
|
||||||
|
|
||||||
|
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||||
|
if not isinstance(w, Process):
|
||||||
|
return await w
|
||||||
|
if retcode := await w.wait():
|
||||||
|
cmd = self._cmds[w]
|
||||||
|
logger.warning(
|
||||||
|
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||||
|
)
|
||||||
|
raise CalledProcessError(retcode, cmd)
|
||||||
|
return retcode
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as group:
|
||||||
|
tasks = [group.create_task(task(w)) for w in waitables]
|
||||||
|
|
||||||
|
return tuple(task.result() for task in tasks)
|
||||||
|
|
||||||
|
|
||||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||||
@@ -128,31 +107,32 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
|||||||
writer.close()
|
writer.close()
|
||||||
except OSError, EOFError, ValueError, TimeoutError:
|
except OSError, EOFError, ValueError, TimeoutError:
|
||||||
return None
|
return None
|
||||||
for line in data.decode("latin-1").split("\r\n"):
|
for line in data.decode(errors="replace").split("\r\n"):
|
||||||
if line.lower().startswith("server:"):
|
if line.lower().startswith("server:"):
|
||||||
return line.split(":", 1)[1].strip()
|
return line[7:].strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
async def check_ports_free(*urls: str) -> None:
|
||||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
"""Verify URLs are not responding (ports are free).
|
||||||
|
|
||||||
async def check(url: str) -> None:
|
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||||
server = await http_get_server(url, timeout=0.1)
|
RuntimeError (handled like a failed process) if any URL responds.
|
||||||
|
"""
|
||||||
|
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||||
|
for url, server in zip(urls, servers, strict=True):
|
||||||
if server is not None:
|
if server is not None:
|
||||||
logger.warning(
|
logger.error(
|
||||||
"Conflicting %s already running at %s", server or "server", url
|
"Conflicting %s already running at %s", server or "server", url
|
||||||
)
|
)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
|
|
||||||
await asyncio.gather(*[check(url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
Use empty path to disable the check and make this return immediately.
|
Use empty path to disable the check and make this return immediately.
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
@@ -162,8 +142,8 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
|||||||
logger.info("✓ Backend ready!")
|
logger.info("✓ Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ def main() -> None:
|
|||||||
|
|
||||||
print(f"\nBuild completed successfully for version {version}.")
|
print(f"\nBuild completed successfully for version {version}.")
|
||||||
print("To publish, review the artifacts and run:")
|
print("To publish, review the artifacts and run:")
|
||||||
print(" uv publish && cd paskia-js && npm publish")
|
print("\nuv publish && cd paskia-js && npm publish")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user