pipekit/pipekit/cli.py
Paul Trowbridge c205b48be2 Honor api_host in config.yaml; ignore .venv/ created by deploy.sh.
cmd_serve now reads api_host from Config with a 127.0.0.1 safe default,
matching the existing api_port pattern. --host/--port CLI flags still
override. Local config is bumped to bind 0.0.0.0:8200.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 00:33:56 -04:00

352 lines
11 KiB
Python

"""Pipekit CLI — `pipekit doctor`, `pipekit init`, later `serve` and `tui`."""
from __future__ import annotations
import argparse
import sys
from . import __version__
from . import db, drivers, engine, jrunner, repo
from .config import get_config
def cmd_init(args) -> int:
db.init_db()
print(f"initialised {get_config().database}")
return 0
def cmd_doctor(args) -> int:
checks: list[tuple[str, bool, str]] = []
try:
cfg = get_config()
checks.append(("config", True, str(cfg.source)))
except Exception as e:
checks.append(("config", False, f"{type(e).__name__}: {e}"))
_report(checks)
return 1
ok, msg = jrunner.version()
checks.append(("jrunner", ok, msg))
ok, msg = db.ping()
checks.append(("database", ok, msg))
return _report(checks)
def cmd_drivers_list(args) -> int:
kinds = drivers.available_kinds()
width = max(len(k) for k, _ in kinds)
print("available drivers:")
for kind, label in kinds:
print(f" {kind.ljust(width)} {label}")
return 0
_DEFAULT_JDBC_CLASSES = {
"db2": "com.ibm.as400.access.AS400JDBCDriver",
"pg": "org.postgresql.Driver",
"mssql": "com.microsoft.sqlserver.jdbc.SQLServerDriver",
}
def cmd_drivers_register(args) -> int:
try:
drivers.get_driver(args.kind)
except ValueError as e:
print(f"error: {e}")
return 1
existing = [d for d in repo.list_drivers() if d["kind"] == args.kind]
if existing and not args.force:
print(f"driver kind {args.kind!r} already registered as "
f"{existing[0]['name']!r} (id={existing[0]['id']}). "
f"Use --force to add a second row.")
return 0
class_name = args.class_name or _DEFAULT_JDBC_CLASSES.get(args.kind)
if not class_name:
print(f"error: no built-in JDBC class for kind {args.kind!r}; "
f"pass --class explicitly")
return 1
import os
if not os.path.exists(args.jar):
print(f"warning: jar {args.jar!r} does not exist "
f"(registering anyway)")
name = args.name or f"{args.kind}-jdbc"
row = repo.create_driver(
name=name, kind=args.kind, jar_file=args.jar,
class_name=class_name, url_template=args.url_template,
)
print(f"registered driver id={row['id']} name={row['name']!r} "
f"kind={row['kind']!r}")
return 0
def cmd_drivers_show(args) -> int:
try:
d = drivers.get_driver(args.kind)
except ValueError as e:
print(f"error: {e}")
return 1
fields = d.browse_fields()
print(f"driver: {d.kind} {d.label}")
print(f"wizard browse fields ({len(fields)}):")
for f in fields:
req = "required" if f.required else "optional"
default = f" default={f.default!r}" if f.default else ""
help_ = f"{f.help}" if f.help else ""
print(f" {f.name:<16} {req:<8} [{f.label}]{default}{help_}")
return 0
def cmd_run(args) -> int:
module = repo.get_module_by_name(args.module)
if module is None:
print(f"error: module {args.module!r} not found")
return 1
try:
outcome = engine.run_module(module["id"], dry_run=args.dry_run)
except engine.LockBusy as e:
print(f"busy: {e}")
return 1
tag = "DRY RUN — no jrunner calls made" if args.dry_run else ""
print(f"run_id={outcome.run_id} status={outcome.status} "
f"rows={outcome.row_count} {tag}".rstrip())
print()
if outcome.resolved_source_sql:
print("-- resolved source SQL --")
print(outcome.resolved_source_sql)
print()
if outcome.merge_sql:
print("-- merge SQL --")
print(outcome.merge_sql)
print()
if outcome.error:
print("-- error --")
print(outcome.error)
return 0 if outcome.status == "success" else 1
def cmd_serve(args) -> int:
import uvicorn
from .api import create_app
cfg = get_config()
host = args.host or cfg.api_host
port = args.port or cfg.api_port
uvicorn.run(create_app(), host=host, port=port, reload=args.reload)
return 0
def cmd_set_password(args) -> int:
import getpass
pw = getpass.getpass(f"password for {args.username}: ")
if not pw:
print("error: empty password")
return 1
repo.set_setting("api_user", args.username)
repo.set_setting("api_pass", pw)
print(f"credentials saved for user {args.username!r}")
print("(set `api_auth_enabled: true` in config.yaml to enforce)")
return 0
_DEFAULT_SECRETS_FILE = "/etc/pipekit/secrets.env"
def _secrets_path(args) -> str:
import os
return (getattr(args, "file", None)
or os.environ.get("PIPEKIT_SECRETS")
or _DEFAULT_SECRETS_FILE)
def cmd_secrets_list(args) -> int:
import os
path = _secrets_path(args)
if not os.path.exists(path):
print(f"{path}: no such file")
return 0
keys: list[str] = []
with open(path) as f:
for line in f:
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
keys.append(s.split("=", 1)[0])
print(f"{path}{len(keys)} secret(s):")
for k in keys:
print(f" {k}")
return 0
def cmd_secrets_set(args) -> int:
import getpass
import os
import stat
path = _secrets_path(args)
value = args.value if args.value is not None else getpass.getpass(
f"value for {args.key}: ")
if not value:
print("error: empty value")
return 1
lines: list[str] = []
replaced = False
if os.path.exists(path):
with open(path) as f:
for line in f:
s = line.strip()
if s and not s.startswith("#") and "=" in s \
and s.split("=", 1)[0] == args.key:
lines.append(f"{args.key}={value}\n")
replaced = True
else:
lines.append(line)
if not replaced:
if lines and not lines[-1].endswith("\n"):
lines.append("\n")
lines.append(f"{args.key}={value}\n")
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.writelines(lines)
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0600
os.replace(tmp, path)
print(f"{'updated' if replaced else 'added'} {args.key} in {path}")
return 0
def cmd_secrets_unset(args) -> int:
import os
path = _secrets_path(args)
if not os.path.exists(path):
print(f"{path}: no such file")
return 1
kept: list[str] = []
removed = False
with open(path) as f:
for line in f:
s = line.strip()
if s and not s.startswith("#") and "=" in s \
and s.split("=", 1)[0] == args.key:
removed = True
continue
kept.append(line)
if not removed:
print(f"{args.key} not set in {path}")
return 0
tmp = path + ".tmp"
with open(tmp, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
print(f"removed {args.key} from {path}")
return 0
def _report(checks) -> int:
width = max(len(name) for name, _, _ in checks)
failures = 0
for name, ok, msg in checks:
mark = "OK " if ok else "FAIL"
print(f" [{mark}] {name.ljust(width)} {msg}")
if not ok:
failures += 1
print()
if failures:
print(f"{failures} check(s) failed")
return 1
print("all checks passed")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="pipekit")
p.add_argument("--version", action="version", version=f"pipekit {__version__}")
sub = p.add_subparsers(dest="cmd", required=True)
p_init = sub.add_parser("init", help="create/upgrade the SQLite schema")
p_init.set_defaults(func=cmd_init)
p_doc = sub.add_parser("doctor", help="check config, jrunner, database")
p_doc.set_defaults(func=cmd_doctor)
p_drv = sub.add_parser("drivers", help="inspect the driver registry")
drv_sub = p_drv.add_subparsers(dest="drv_cmd", required=True)
p_drv_list = drv_sub.add_parser("list", help="list available drivers")
p_drv_list.set_defaults(func=cmd_drivers_list)
p_drv_show = drv_sub.add_parser("show", help="show a driver's wizard browse fields")
p_drv_show.add_argument("kind", help="one of the kinds from `pipekit drivers list`")
p_drv_show.set_defaults(func=cmd_drivers_show)
p_drv_reg = drv_sub.add_parser(
"register", help="add a driver row to the database")
p_drv_reg.add_argument("kind", help="driver kind (db2, pg, mssql)")
p_drv_reg.add_argument("--jar", required=True,
help="absolute path to the JDBC jar")
p_drv_reg.add_argument("--name",
help="registry name (default: <kind>-jdbc)")
p_drv_reg.add_argument("--class", dest="class_name",
help="JDBC Driver class (default: built-in per kind)")
p_drv_reg.add_argument("--url-template",
help="optional JDBC URL template for the wizard")
p_drv_reg.add_argument("--force", action="store_true",
help="register even if a row for this kind exists")
p_drv_reg.set_defaults(func=cmd_drivers_register)
p_run = sub.add_parser("run", help="run a module by name (synchronous)")
p_run.add_argument("module", help="module name")
p_run.add_argument("--dry-run", action="store_true",
help="build SQL but do not invoke jrunner")
p_run.set_defaults(func=cmd_run)
p_serve = sub.add_parser("serve", help="start the HTTP API")
p_serve.add_argument("--host", default=None,
help="defaults to config.yaml api_host (127.0.0.1)")
p_serve.add_argument("--port", type=int, default=None,
help="defaults to config.yaml api_port")
p_serve.add_argument("--reload", action="store_true")
p_serve.set_defaults(func=cmd_serve)
p_pw = sub.add_parser("set-password", help="set API Basic Auth credentials")
p_pw.add_argument("username")
p_pw.set_defaults(func=cmd_set_password)
p_sec = sub.add_parser(
"secrets",
help=f"manage the env-var file for DB passwords (default {_DEFAULT_SECRETS_FILE})")
sec_sub = p_sec.add_subparsers(dest="sec_cmd", required=True)
p_sec_list = sec_sub.add_parser("list", help="list keys in the secrets file")
p_sec_list.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
p_sec_list.set_defaults(func=cmd_secrets_list)
p_sec_set = sec_sub.add_parser("set", help="add or update a KEY (prompted if value omitted)")
p_sec_set.add_argument("key")
p_sec_set.add_argument("value", nargs="?", default=None,
help="value; omit to be prompted (safer)")
p_sec_set.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
p_sec_set.set_defaults(func=cmd_secrets_set)
p_sec_unset = sec_sub.add_parser("unset", help="remove a KEY from the secrets file")
p_sec_unset.add_argument("key")
p_sec_unset.add_argument("--file", help=f"override path (default {_DEFAULT_SECRETS_FILE})")
p_sec_unset.set_defaults(func=cmd_secrets_unset)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())