# retoor import argparse import sys from devplacepy.database import get_table from devplacepy.utils import strip_html def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None): from devplacepy.services.audit import record as audit audit.record_system( event_key, actor_kind="cli", actor_role="system", origin="cli", target_type=target_type, target_uid=target_uid, target_label=target_label, summary=summary, metadata=metadata, links=links, ) def cmd_role_get(args): users = get_table("users") user = users.find_one(username=args.username) if not user: print(f"User '{args.username}' not found") sys.exit(1) print(user.get("role", "member").lower()) def cmd_role_set(args): role = args.role.lower() if role not in ("member", "admin"): print("Role must be 'member' or 'admin'") sys.exit(1) users = get_table("users") user = users.find_one(username=args.username) if not user: print(f"User '{args.username}' not found") sys.exit(1) old_role = user.get("role") users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"]) from devplacepy.services.audit import record as audit _audit_cli( "cli.role.set", f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}", metadata={"old": old_role, "new": role.capitalize()}, target_type="user", target_uid=user["uid"], target_label=args.username, links=[audit.target("user", user["uid"], args.username)], ) print(f"User '{args.username}' role set to '{role}'") def cmd_apikey_get(args): users = get_table("users") user = users.find_one(username=args.username) if not user: print(f"User '{args.username}' not found") sys.exit(1) print(user.get("api_key", "") or "") def cmd_apikey_reset(args): from devplacepy.utils import generate_uid, clear_user_cache users = get_table("users") user = users.find_one(username=args.username) if not user: print(f"User '{args.username}' not found") sys.exit(1) new_key = generate_uid() users.update({"uid": user["uid"], "api_key": new_key}, ["uid"]) clear_user_cache(user["uid"]) from devplacepy.services.audit import record as audit _audit_cli( "cli.apikey.reset", f"CLI regenerated the API key of user {args.username}", target_type="user", target_uid=user["uid"], target_label=args.username, links=[audit.target("user", user["uid"], args.username)], ) print(new_key) def cmd_apikey_backfill(args): from devplacepy.database import backfill_api_keys updated = backfill_api_keys() _audit_cli( "cli.apikey.backfill", f"CLI backfilled API keys for {updated} users", metadata={"count": updated}, ) print(f"Assigned API keys to {updated} user(s) without one") def cmd_devii_reset_quota(args): from devplacepy.database import db table_name = "devii_usage_ledger" if table_name not in db.tables: print(f"Table '{table_name}' does not exist, nothing to reset") return table = db[table_name] if args.all: count = table.count() table.delete() _audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count}) print(f"Reset all AI quotas ({count} ledger rows deleted)") return if args.guests: count = table.count(owner_kind="guest") table.delete(owner_kind="guest") _audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count}) print(f"Reset all guest AI quotas ({count} ledger rows deleted)") return if not args.username: print("Provide a username, or --guests, or --all") sys.exit(1) user = get_table("users").find_one(username=args.username) if not user: print(f"User '{args.username}' not found") sys.exit(1) count = table.count(owner_kind="user", owner_id=user["uid"]) table.delete(owner_kind="user", owner_id=user["uid"]) from devplacepy.services.audit import record as audit _audit_cli( "cli.devii.quota.reset", f"CLI reset AI quota for {args.username}", metadata={"scope": "user", "rows_removed": count}, target_type="user", target_uid=user["uid"], target_label=args.username, links=[audit.target("user", user["uid"], args.username)], ) print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)") def cmd_news_clear(args): from devplacepy.database import db deleted = {} for table in ("news", "news_images", "news_sync"): if table in db.tables: count = db[table].count() db[table].delete() deleted[table] = count print(f"Deleted {count} rows from '{table}'") else: print(f"Table '{table}' does not exist, skipping") _audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted}) print("News data cleared") def cmd_news_sanitize(args): from devplacepy.database import db if "news" not in db.tables: print("News table does not exist") return news_table = db["news"] updated = 0 for row in news_table.all(): desc = (strip_html(row.get("description", "") or ""))[:5000] content = (strip_html(row.get("content", "") or ""))[:10000] if desc != row.get("description", "") or content != row.get("content", ""): news_table.update( {"id": row["id"], "description": desc, "content": content}, ["id"] ) updated += 1 _audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated}) print(f"Sanitized {updated} news article(s)") def cmd_attachments_prune(args): from datetime import datetime, timezone, timedelta from devplacepy.database import db from devplacepy.attachments import delete_attachment if "attachments" not in db.tables: print("Attachments table does not exist") return cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat() orphans = [ att for att in db["attachments"].find(target_type="", target_uid="") if att.get("created_at", "") < cutoff ] for att in orphans: delete_attachment(att["uid"]) _audit_cli( "cli.attachments.prune", f"CLI pruned {len(orphans)} orphan attachments", metadata={"count": len(orphans), "hours": args.hours}, ) print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h") def _remove_zip_artifacts(job): import shutil from pathlib import Path from devplacepy.services.jobs.zip_service import STAGING_DIR local_path = (job.get("result") or {}).get("local_path") if local_path: Path(local_path).unlink(missing_ok=True) shutil.rmtree(STAGING_DIR / job["uid"], ignore_errors=True) def cmd_zips_prune(args): from datetime import datetime, timezone from devplacepy.services.jobs import queue now = datetime.now(timezone.utc) removed = 0 for job in queue.list_jobs(kind="zip", status=queue.DONE): expires_at = job.get("expires_at") if not expires_at: continue try: expiry = datetime.fromisoformat(expires_at) except (ValueError, TypeError): continue if expiry < now: _remove_zip_artifacts(job) get_table("jobs").delete(uid=job["uid"]) removed += 1 _audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed}) print(f"Pruned {removed} expired zip job(s)") def cmd_zips_clear(args): from devplacepy.services.jobs import queue jobs = queue.list_jobs(kind="zip") for job in jobs: _remove_zip_artifacts(job) get_table("jobs").delete(uid=job["uid"]) _audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)}) print(f"Cleared {len(jobs)} zip job(s) and their archives") def cmd_forks_prune(args): from datetime import datetime, timezone from devplacepy.services.jobs import queue now = datetime.now(timezone.utc) removed = 0 for job in queue.list_jobs(kind="fork", status=queue.DONE): expires_at = job.get("expires_at") if not expires_at: continue try: expiry = datetime.fromisoformat(expires_at) except (ValueError, TypeError): continue if expiry < now: get_table("jobs").delete(uid=job["uid"]) removed += 1 _audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed}) print(f"Pruned {removed} expired fork job(s)") def cmd_forks_clear(args): from devplacepy.services.jobs import queue jobs = queue.list_jobs(kind="fork") for job in jobs: get_table("jobs").delete(uid=job["uid"]) _audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)}) print(f"Cleared {len(jobs)} fork job(s)") def cmd_containers_list(args): from devplacepy.services.containers import store instances = store.all_instances() if not instances: print("No container instances") return for inst in instances: print( f"{inst['uid'][:8]} {inst.get('name', ''):24.24} {inst.get('status', ''):10} " f"desired={inst.get('desired_state', '')} policy={inst.get('restart_policy', '')}" ) def cmd_containers_reconcile(args): import asyncio from devplacepy.services.containers.service import ContainerService asyncio.run(ContainerService().run_once()) _audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass") print("Reconcile pass complete") def cmd_containers_prune(args): import asyncio from devplacepy.services.containers.runtime import get_backend from devplacepy.services.containers.service import ContainerService async def run(): await ContainerService().run_once() await get_backend().image_prune() asyncio.run(run()) _audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images") print("Reaped orphans and pruned dangling images") def cmd_containers_prune_builds(args): import asyncio from devplacepy.database import db, get_table from devplacepy.services.containers.runtime import get_backend async def run(): backend = get_backend() removed = 0 if "builds" in db.tables: for build in list(get_table("builds").find()): tag = build.get("image_tag") if tag: await backend.remove_image(tag) removed += 1 for table in ("builds", "dockerfile_versions", "dockerfiles"): if table in db.tables: get_table(table).delete() return removed removed = asyncio.run(run()) _audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed}) print( f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables" ) def cmd_containers_gc_workspaces(args): import shutil from pathlib import Path from devplacepy import config from devplacepy.services.containers import store active = {inst["project_uid"] for inst in store.all_instances()} base = Path(config.CONTAINER_WORKSPACES_DIR) removed = 0 if base.is_dir(): for child in base.iterdir(): if child.is_dir() and child.name not in active: shutil.rmtree(child, ignore_errors=True) removed += 1 _audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed}) print( f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}" ) def main(): parser = argparse.ArgumentParser(description="DevPlace admin CLI") sub = parser.add_subparsers(title="commands", dest="command") role = sub.add_parser("role", help="Manage user roles") role_sub = role.add_subparsers(title="action", dest="action") role_get = role_sub.add_parser("get", help="Get a user's role") role_get.add_argument("username") role_get.set_defaults(func=cmd_role_get) role_set = role_sub.add_parser("set", help="Set a user's role") role_set.add_argument("username") role_set.add_argument("role", choices=["member", "admin"]) role_set.set_defaults(func=cmd_role_set) apikey = sub.add_parser("apikey", help="Manage user API keys") apikey_sub = apikey.add_subparsers(title="action", dest="action") apikey_get = apikey_sub.add_parser("get", help="Print a user's API key") apikey_get.add_argument("username") apikey_get.set_defaults(func=cmd_apikey_get) apikey_reset = apikey_sub.add_parser("reset", help="Regenerate a user's API key") apikey_reset.add_argument("username") apikey_reset.set_defaults(func=cmd_apikey_reset) apikey_backfill = apikey_sub.add_parser( "backfill", help="Assign API keys to users that lack one" ) apikey_backfill.set_defaults(func=cmd_apikey_backfill) news = sub.add_parser("news", help="News management") news_sub = news.add_subparsers(title="action", dest="action") news_clear = news_sub.add_parser( "clear", help="Delete all news from local database" ) news_clear.set_defaults(func=cmd_news_clear) news_sanitize = news_sub.add_parser( "sanitize", help="Strip HTML from all existing news descriptions and content" ) news_sanitize.set_defaults(func=cmd_news_sanitize) attachments = sub.add_parser("attachments", help="Attachment management") att_sub = attachments.add_subparsers(title="action", dest="action") att_prune = att_sub.add_parser( "prune", help="Remove orphaned attachment records and files" ) att_prune.add_argument( "--hours", type=int, default=24, help="Only prune orphans older than this many hours", ) att_prune.set_defaults(func=cmd_attachments_prune) devii = sub.add_parser("devii", help="Devii assistant management") devii_sub = devii.add_subparsers(title="action", dest="action") devii_reset = devii_sub.add_parser( "reset-quota", help="Reset the rolling 24h AI spend quota" ) devii_reset.add_argument( "username", nargs="?", help="Reset the quota for a single user" ) devii_reset.add_argument( "--guests", action="store_true", help="Reset every guest quota" ) devii_reset.add_argument( "--all", action="store_true", help="Reset every quota (users and guests)" ) devii_reset.set_defaults(func=cmd_devii_reset_quota) zips = sub.add_parser("zips", help="Zip archive job management") zips_sub = zips.add_subparsers(title="action", dest="action") zips_prune = zips_sub.add_parser( "prune", help="Delete expired zip archives and their job rows" ) zips_prune.set_defaults(func=cmd_zips_prune) zips_clear = zips_sub.add_parser( "clear", help="Delete every zip archive and job row" ) zips_clear.set_defaults(func=cmd_zips_clear) forks = sub.add_parser("forks", help="Fork job management") forks_sub = forks.add_subparsers(title="action", dest="action") forks_prune = forks_sub.add_parser( "prune", help="Delete expired completed fork job rows (forked projects persist)" ) forks_prune.set_defaults(func=cmd_forks_prune) forks_clear = forks_sub.add_parser( "clear", help="Delete every fork job row (forked projects persist)" ) forks_clear.set_defaults(func=cmd_forks_clear) containers = sub.add_parser("containers", help="Container manager") containers_sub = containers.add_subparsers(title="action", dest="action") containers_sub.add_parser("list", help="List container instances").set_defaults( func=cmd_containers_list ) containers_sub.add_parser("reconcile", help="Run one reconcile pass").set_defaults( func=cmd_containers_reconcile ) containers_sub.add_parser( "prune", help="Reap orphan containers and dangling images" ).set_defaults(func=cmd_containers_prune) containers_sub.add_parser( "prune-builds", help="Remove legacy per-project images and clear the dockerfiles/builds tables", ).set_defaults(func=cmd_containers_prune_builds) containers_sub.add_parser( "gc-workspaces", help="Remove workspace dirs with no instances" ).set_defaults(func=cmd_containers_gc_workspaces) args = parser.parse_args() if hasattr(args, "func"): args.func(args) else: parser.print_help() sys.exit(1) if __name__ == "__main__": main()