build / build (push) Successful in 21m27s
The packaged copies use config.default_path() and pipeline._history_path(): files next to the sources in a checkout, ~/.config/<name> and ~/.local/state/<name> when installed. The copies now differ from the originals only in the neutral defaults. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
172 lines
6.8 KiB
Python
172 lines
6.8 KiB
Python
"""Command-line mode: the same pipeline as the TUI, for scripts and quick deploys."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from . import __version__, config, detect, pipeline, remote, snapshots
|
|
from .kinds import Options, Plan
|
|
|
|
C = {"new": "\033[32m+", "update": "\033[33m~", "delete": "\033[31m-"}
|
|
RESET = "\033[0m"
|
|
|
|
|
|
def color(on: bool):
|
|
if not on:
|
|
C.update(new="+", update="~", delete="-")
|
|
globals()["RESET"] = ""
|
|
|
|
|
|
def print_plan(plan: Plan) -> None:
|
|
a = plan.app
|
|
print(f"\n{a.name} [{a.kind}] {plan.rsync_src} -> {plan.server.target}:{plan.dest}"
|
|
f" {('rev ' + plan.git_rev) if plan.git_rev else ''}")
|
|
for c in plan.changes:
|
|
print(f" {C[c.action]} {c.path}{RESET}")
|
|
if not plan.changes:
|
|
print(" (no file changes)")
|
|
for n in plan.notes:
|
|
print(f" ! {n}")
|
|
|
|
|
|
def confirm(question: str, yes: bool) -> bool:
|
|
if yes:
|
|
return True
|
|
if not sys.stdin.isatty():
|
|
print("not a terminal: pass --yes to confirm", file=sys.stderr)
|
|
return False
|
|
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes", "т", "так")
|
|
|
|
|
|
def options(args) -> Options:
|
|
return Options(build=not args.no_build, install_deps=args.deps, restart=not args.no_restart,
|
|
copy_configs=not args.no_configs, delete=args.delete)
|
|
|
|
|
|
def cmd_list(cfg, args):
|
|
st = {} if args.offline else pipeline.status_all(cfg)
|
|
for a in cfg.apps:
|
|
state, last, code = st.get(a.name, ("", "", ""))
|
|
print(f"{a.name:22} {a.kind:15} {state:14} {code:8} {cfg.dest_path(a):48} {last}")
|
|
|
|
|
|
def cmd_deploy(cfg, args):
|
|
app = cfg.app(args.app)
|
|
plan = pipeline.preview(cfg, app, options(args), log=print)
|
|
print_plan(plan)
|
|
if args.dry_run:
|
|
return 0
|
|
if not plan.changes and not plan.first_install and not args.force:
|
|
print("nothing to sync: use --force to run install/restart anyway")
|
|
return 0
|
|
if not confirm("deploy?", args.yes):
|
|
return 1
|
|
res = pipeline.execute(plan, log=print)
|
|
if not res.ok and res.snapshot and confirm(f"roll back to {res.snapshot}?", False):
|
|
res = pipeline.rollback(cfg, app, res.snapshot, log=print)
|
|
return 0 if res.ok else 2
|
|
|
|
|
|
def cmd_rollback(cfg, args):
|
|
app = cfg.app(args.app)
|
|
snaps = snapshots.listing(cfg.server, app.name)
|
|
if args.list or not snaps:
|
|
print("\n".join(snaps) or "no snapshots")
|
|
return 0
|
|
ts = args.ts or snaps[0]
|
|
if not confirm(f"restore {app.name} from {ts}?", args.yes):
|
|
return 1
|
|
res = pipeline.rollback(cfg, app, ts, log=print)
|
|
print(res.message)
|
|
return 0 if res.ok else 2
|
|
|
|
|
|
def cmd_logs(cfg, args):
|
|
return remote.interactive(cfg.server, pipeline.logs_command(cfg, cfg.app(args.app)))
|
|
|
|
|
|
def cmd_import(cfg, args):
|
|
found = detect.import_server(cfg, log=print)
|
|
if not found:
|
|
print("nothing new on the server")
|
|
return 0
|
|
ready = [i.app for i in found if i.matched and i.app.kind.startswith("python")]
|
|
drafts = [i.app for i in found if i.app not in ready]
|
|
print(f"\n{len(ready)} ready, {len(drafts)} as commented drafts")
|
|
if confirm(f"append to {cfg.path}?", args.yes):
|
|
config.append_apps(cfg.path, ready, drafts)
|
|
return 0
|
|
|
|
|
|
def cmd_add(cfg, args):
|
|
app = detect.new_app(Path(args.path), args.name, args.kind)
|
|
print(config.app_to_toml(app))
|
|
if confirm(f"append to {cfg.path}?", args.yes):
|
|
config.append_apps(cfg.path, [app])
|
|
return 0
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
p = argparse.ArgumentParser(prog="deploy", description="Deploy local projects to the server. "
|
|
"No command: start the TUI.")
|
|
p.add_argument("--config", default=None, help="deploy.toml (default: next to deploy.py)")
|
|
p.add_argument("--no-color", action="store_true")
|
|
p.add_argument("-V", "--version", action="version", version=__version__)
|
|
sub = p.add_subparsers(dest="cmd")
|
|
|
|
s = sub.add_parser("list", help="apps and their state on the server")
|
|
s.add_argument("--offline", action="store_true", help="do not ask the server")
|
|
for name, hlp in (("deploy", "preview and deploy an app"), ("preview", "show what a deploy would change")):
|
|
s = sub.add_parser(name, help=hlp)
|
|
s.add_argument("app")
|
|
s.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
|
|
s.add_argument("-n", "--dry-run", action="store_true", default=name == "preview")
|
|
s.add_argument("--force", action="store_true", help="install/restart even without file changes")
|
|
s.add_argument("--no-build", action="store_true")
|
|
s.add_argument("--no-restart", action="store_true", help="no restart, no post command, no health check")
|
|
s.add_argument("--no-configs", action="store_true", help="do not create missing config links")
|
|
s.add_argument("--deps", action="store_true", help="pip install even if requirements.txt is unchanged")
|
|
s.add_argument("--delete", action=argparse.BooleanOptionalAction, default=None,
|
|
help="delete server files that are absent locally (default: app setting)")
|
|
s = sub.add_parser("rollback", help="restore the latest (or given) snapshot")
|
|
s.add_argument("app")
|
|
s.add_argument("ts", nargs="?")
|
|
s.add_argument("--list", action="store_true")
|
|
s.add_argument("-y", "--yes", action="store_true")
|
|
s = sub.add_parser("logs", help="follow the app log")
|
|
s.add_argument("app")
|
|
s = sub.add_parser("import", help="add apps that already run on the server to deploy.toml")
|
|
s.add_argument("-y", "--yes", action="store_true")
|
|
s = sub.add_parser("add", help="add a local project to deploy.toml")
|
|
s.add_argument("path")
|
|
s.add_argument("--name")
|
|
s.add_argument("--kind", choices=config.KINDS)
|
|
s.add_argument("-y", "--yes", action="store_true")
|
|
sub.add_parser("check", help="validate deploy.toml")
|
|
sub.add_parser("tui", help="start the TUI (default)")
|
|
|
|
args = p.parse_args(argv)
|
|
color(sys.stdout.isatty() and not args.no_color)
|
|
cfg_path = Path(args.config) if args.config else config.default_path()
|
|
try:
|
|
cfg = config.load(cfg_path)
|
|
except (OSError, config.ConfigError) as e:
|
|
print(f"config: {e}", file=sys.stderr)
|
|
return 1
|
|
if args.cmd in (None, "tui"):
|
|
from .tui import run
|
|
return run(cfg)
|
|
if args.cmd == "check":
|
|
print(f"{cfg_path}: {len(cfg.apps)} apps, OK")
|
|
return 0
|
|
handlers = {"list": cmd_list, "deploy": cmd_deploy, "preview": cmd_deploy, "rollback": cmd_rollback,
|
|
"logs": cmd_logs, "import": cmd_import, "add": cmd_add}
|
|
try:
|
|
return handlers[args.cmd](cfg, args) or 0
|
|
except (config.ConfigError, pipeline.DeployError, remote.RemoteError) as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return 1
|
|
except KeyboardInterrupt:
|
|
return 130
|