From d556b02bfbc2a2b79974956099dd92c55f5e9d95 Mon Sep 17 00:00:00 2001 From: retoor Date: Sat, 4 Jul 2026 19:17:59 +0000 Subject: [PATCH] refactor: split cli.py into package (ref.md 3.7) --- devplacepy/cli.py | 1080 --------------------------------- devplacepy/cli/__init__.py | 83 +++ devplacepy/cli/__main__.py | 6 + devplacepy/cli/_shared.py | 18 + devplacepy/cli/apikeys.py | 65 ++ devplacepy/cli/attachments.py | 43 ++ devplacepy/cli/backups.py | 82 +++ devplacepy/cli/containers.py | 107 ++++ devplacepy/cli/devii.py | 66 ++ devplacepy/cli/jobs.py | 267 ++++++++ devplacepy/cli/main.py | 46 ++ devplacepy/cli/migrate.py | 233 +++++++ devplacepy/cli/news.py | 53 ++ devplacepy/cli/roles.py | 57 ++ devplacepy/cli/tokens.py | 125 ++++ 15 files changed, 1251 insertions(+), 1080 deletions(-) delete mode 100644 devplacepy/cli.py create mode 100644 devplacepy/cli/__init__.py create mode 100644 devplacepy/cli/__main__.py create mode 100644 devplacepy/cli/_shared.py create mode 100644 devplacepy/cli/apikeys.py create mode 100644 devplacepy/cli/attachments.py create mode 100644 devplacepy/cli/backups.py create mode 100644 devplacepy/cli/containers.py create mode 100644 devplacepy/cli/devii.py create mode 100644 devplacepy/cli/jobs.py create mode 100644 devplacepy/cli/main.py create mode 100644 devplacepy/cli/migrate.py create mode 100644 devplacepy/cli/news.py create mode 100644 devplacepy/cli/roles.py create mode 100644 devplacepy/cli/tokens.py diff --git a/devplacepy/cli.py b/devplacepy/cli.py deleted file mode 100644 index d593811a..00000000 --- a/devplacepy/cli.py +++ /dev/null @@ -1,1080 +0,0 @@ -# retoor - -import argparse -import sys -from devplacepy.database import get_table, invalidate_admins_cache -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"]) - invalidate_admins_cache() - 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_token_issue(args): - """Issue a DevPlace access token for a user.""" - from devplacepy.services.access_tokens import issue_token - - users = get_table("users") - user = users.find_one(username=args.username) - if not user: - print(f"User '{args.username}' not found") - sys.exit(1) - - result = issue_token(user, label=args.label or "cli") - _audit_cli( - "cli.token.issue", - f"CLI issued access token for {args.username}", - target_type="user", - target_uid=user["uid"], - target_label=args.username, - metadata={"token_uid": result["uid"]}, - ) - print(result["access_token"]) - - -def cmd_token_list(args): - """List active access tokens for a user.""" - from datetime import datetime, timezone - - users = get_table("users") - user = users.find_one(username=args.username) - if not user: - print(f"User '{args.username}' not found") - sys.exit(1) - - tokens = get_table("access_tokens") - now = datetime.now(timezone.utc) - found = False - for t in tokens.find(user_uid=user["uid"], deleted_at=None): - found = True - expires_at = t.get("expires_at", "") - try: - expires = datetime.fromisoformat(expires_at) - if expires.tzinfo is None: - expires = expires.replace(tzinfo=timezone.utc) - status = "expired" if expires < now else "active" - except (ValueError, TypeError): - status = "unknown" - label = t.get("label", "") or "-" - print( - f" uid={t['uid']} token={t['token'][:12]}... " - f"label={label} expires={expires_at} status={status}" - ) - if not found: - print(f"No active tokens for '{args.username}'") - - -def cmd_token_revoke(args): - """Revoke a single access token by uid.""" - from devplacepy.services.access_tokens import revoke_token - - ok = revoke_token(args.token_uid) - if not ok: - print(f"Token uid='{args.token_uid}' not found or already revoked") - sys.exit(1) - _audit_cli( - "cli.token.revoke", - f"CLI revoked access token uid={args.token_uid}", - metadata={"token_uid": args.token_uid}, - ) - print(f"Revoked token uid='{args.token_uid}'") - - -def cmd_token_revoke_all(args): - """Revoke all access tokens for a user.""" - from devplacepy.services.access_tokens import revoke_all - - users = get_table("users") - user = users.find_one(username=args.username) - if not user: - print(f"User '{args.username}' not found") - sys.exit(1) - - count = revoke_all(user["uid"]) - _audit_cli( - "cli.token.revoke_all", - f"CLI revoked all access tokens for {args.username}", - target_type="user", - target_uid=user["uid"], - target_label=args.username, - metadata={"count": count}, - ) - print(f"Revoked {count} token(s) for '{args.username}'") - - -def cmd_token_prune(args): - """Soft-delete all expired access tokens.""" - from devplacepy.services.access_tokens import prune_expired - - count = prune_expired() - _audit_cli( - "cli.token.prune", - f"CLI pruned {count} expired access tokens", - metadata={"count": count}, - ) - print(f"Pruned {count} expired token(s)") - - -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_backups_list(args): - from devplacepy.services.backup import store - - backups = store.list_backups() - if not backups: - print("No backups recorded") - return - for backup in backups: - size = store.human_bytes(int(backup.get("size_bytes") or 0)) - print( - f"{backup['uid']} {backup.get('target', ''):<9} " - f"{backup.get('status', ''):<8} {size:>10} " - f"{backup.get('created_at', '')} {backup.get('filename', '')}" - ) - - -def cmd_backups_run(args): - from devplacepy.services.backup import store - from devplacepy.services.jobs import queue - - if not store.is_valid_target(args.target): - print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}") - sys.exit(1) - job_uid = queue.enqueue( - "backup", - {"target": args.target, "schedule_uid": "", "created_by": "cli"}, - owner_kind="system", - owner_id="cli", - preferred_name=f"{store.target_label(args.target)} (cli)", - ) - store.create_backup(target=args.target, created_by="cli", job_uid=job_uid) - _audit_cli( - "cli.backups.run", - f"CLI enqueued {args.target} backup", - metadata={"target": args.target, "job_uid": job_uid}, - ) - print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)") - - -def cmd_backups_prune(args): - from devplacepy.services.backup import store - - removed = store.prune_orphans() - _audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed}) - print(f"Pruned {removed} orphan backup record(s)") - - -def cmd_backups_clear(args): - from devplacepy.services.backup import store - - removed = store.clear_all() - _audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed}) - print(f"Cleared {removed} backup(s) and their archives") - - -def _remove_seo_artifacts(job): - import shutil - from devplacepy.config import SEO_REPORTS_DIR - - shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True) - - -def cmd_seo_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="seo", 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_seo_artifacts(job) - get_table("jobs").delete(uid=job["uid"]) - removed += 1 - _audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed}) - print(f"Pruned {removed} expired SEO audit(s)") - - -def cmd_seo_clear(args): - from devplacepy.services.jobs import queue - - jobs = queue.list_jobs(kind="seo") - for job in jobs: - _remove_seo_artifacts(job) - get_table("jobs").delete(uid=job["uid"]) - _audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)}) - print(f"Cleared {len(jobs)} SEO audit(s) and their reports") - - -def cmd_seo_meta_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="seo_meta", 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.seo_meta.prune", - f"CLI pruned {removed} expired SEO metadata jobs", - metadata={"count": removed}, - ) - print(f"Pruned {removed} expired SEO metadata job(s)") - - -def cmd_seo_meta_clear(args): - from devplacepy.services.jobs import queue - - jobs = queue.list_jobs(kind="seo_meta") - for job in jobs: - get_table("jobs").delete(uid=job["uid"]) - _audit_cli( - "cli.seo_meta.clear", - f"CLI cleared all SEO metadata jobs ({len(jobs)})", - metadata={"count": len(jobs)}, - ) - print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists") - - -def _remove_deepsearch_artifacts(job): - import shutil - from devplacepy.config import DEEPSEARCH_DIR - from devplacepy.services.deepsearch.store import VectorStore - - uid = job["uid"] - collection = f"ds_{uid.replace('-', '')}" - VectorStore(collection).drop() - shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True) - - -def cmd_deepsearch_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="deepsearch", 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_deepsearch_artifacts(job) - get_table("jobs").delete(uid=job["uid"]) - removed += 1 - _audit_cli( - "cli.deepsearch.prune", - f"CLI pruned {removed} expired DeepSearch jobs", - metadata={"count": removed}, - ) - print(f"Pruned {removed} expired DeepSearch job(s)") - - -def cmd_deepsearch_clear(args): - from devplacepy.services.jobs import queue - - jobs = queue.list_jobs(kind="deepsearch") - for job in jobs: - _remove_deepsearch_artifacts(job) - get_table("jobs").delete(uid=job["uid"]) - _audit_cli( - "cli.deepsearch.clear", - f"CLI cleared all DeepSearch jobs ({len(jobs)})", - metadata={"count": len(jobs)}, - ) - print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections") - - -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 _crc32(path): - import zlib - - crc = 0 - with open(path, "rb") as handle: - while True: - chunk = handle.read(1024 * 1024) - if not chunk: - break - crc = zlib.crc32(chunk, crc) - return crc & 0xFFFFFFFF - - -def _migrate_file(source, dest, dry_run, report): - import os - import shutil - - if not source.exists(): - return - if source.resolve() == dest.resolve(): - return - size = source.stat().st_size - if dest.exists(): - if dest.stat().st_size == size and _crc32(dest) == _crc32(source): - report.append(("done", source, dest, size)) - if not dry_run: - source.unlink() - return - report.append(("conflict", source, dest, size)) - return - report.append(("move", source, dest, size)) - if dry_run: - return - dest.parent.mkdir(parents=True, exist_ok=True) - tmp = dest.with_name(dest.name + ".migrating") - shutil.copyfile(source, tmp) - with open(tmp, "rb") as handle: - os.fsync(handle.fileno()) - if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source): - tmp.unlink(missing_ok=True) - raise RuntimeError(f"verification failed copying {source} -> {dest}") - os.replace(tmp, dest) - source.unlink() - - -def _prune_empty_dirs(root): - if not root.exists(): - return - for path in sorted(root.rglob("*"), reverse=True): - if path.is_dir(): - try: - path.rmdir() - except OSError: - pass - try: - root.rmdir() - except OSError: - pass - - -def _migrate_tree(source, dest, dry_run, report): - if not source.exists(): - return - if source.resolve() == dest.resolve(): - return - for child in sorted(source.rglob("*")): - if child.is_file(): - _migrate_file(child, dest / child.relative_to(source), dry_run, report) - if not dry_run: - _prune_empty_dirs(source) - - -def _db_is_locked(path): - import sqlite3 - - try: - conn = sqlite3.connect(str(path), timeout=0.5) - try: - conn.execute("BEGIN IMMEDIATE") - conn.rollback() - return False - finally: - conn.close() - except sqlite3.OperationalError: - return True - - -def _checkpoint(path): - import sqlite3 - - conn = sqlite3.connect(str(path), timeout=5) - try: - conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") - conn.commit() - finally: - conn.close() - - -def _migrate_db(source, dest, dry_run, report): - if not source.exists(): - return - if source.resolve() == dest.resolve(): - return - if _db_is_locked(source): - raise RuntimeError( - f"{source} is locked - stop the app before running migrate-data" - ) - if not dry_run: - _checkpoint(source) - _migrate_file(source, dest, dry_run, report) - for suffix in ("-wal", "-shm"): - _migrate_file( - source.with_name(source.name + suffix), - dest.with_name(dest.name + suffix), - dry_run, - report, - ) - - -def cmd_emoji_sync(args): - from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module - - count = write_emoji_module() - _audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count}) - print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}") - - -def cmd_migrate_data(args): - import os - from pathlib import Path - from collections import Counter - from devplacepy import config - - base = config.BASE_DIR - home = Path.home() - dry = args.dry_run - report = [] - - config.ensure_data_dirs() - - db_items = [] - if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}": - db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db")) - else: - print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.") - if not os.environ.get("DEVII_TASKS_DB"): - db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB)) - if not os.environ.get("DEVII_LESSONS_DB"): - db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB)) - - file_items = [ - (base / name, config.KEYS_DIR / name) - for name in ( - "notification-private.pem", - "notification-private.pkcs8.pem", - "notification-public.pem", - ) - ] - registry_dest = config.BOT_DIR / "article_registry.json" - registry_sources = [ - path - for path in ( - home / ".dpbot_article_registry.json", - base / ".dpbot_article_registry.json", - ) - if path.exists() - ] - registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True) - if registry_sources: - file_items.append((registry_sources[0], registry_dest)) - for stale in registry_sources[1:]: - print(f"Leaving older duplicate registry untouched: {stale}") - - legacy_var = base / "var" - tree_items = [ - (base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR), - (home / ".devplace_bots", config.BOT_DIR), - ] - for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"): - tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name])) - - try: - for source, dest in db_items: - _migrate_db(source, dest, dry, report) - for source, dest in file_items: - _migrate_file(source, dest, dry, report) - for source, dest in tree_items: - _migrate_tree(source, dest, dry, report) - except RuntimeError as exc: - print(f"ERROR: {exc}") - sys.exit(1) - - if not report: - print("Nothing to migrate; the data directory is already consolidated.") - return - for status, source, dest, size in report: - print(f" [{status}] {source} -> {dest} ({size} bytes)") - counts = Counter(status for status, *_ in report) - print() - print( - ("Planned: " if dry else "Migrated: ") - + ", ".join(f"{count} {status}" for status, count in sorted(counts.items())) - ) - if any(status == "conflict" for status, *_ in report): - print( - "Conflicts left both source and destination untouched; resolve them by hand." - ) - if dry: - print("Dry run - nothing changed. Re-run without --dry-run to apply.") - - -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) - - token = sub.add_parser("token", help="Manage DevPlace access tokens") - token_sub = token.add_subparsers(title="action", dest="action") - token_issue = token_sub.add_parser("issue", help="Issue an access token for a user") - token_issue.add_argument("username") - token_issue.add_argument("--label", default="cli", help="Optional label for the token") - token_issue.set_defaults(func=cmd_token_issue) - token_list = token_sub.add_parser("list", help="List a user's active access tokens") - token_list.add_argument("username") - token_list.set_defaults(func=cmd_token_list) - token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid") - token_revoke.add_argument("token_uid") - token_revoke.set_defaults(func=cmd_token_revoke) - token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user") - token_revoke_all.add_argument("username") - token_revoke_all.set_defaults(func=cmd_token_revoke_all) - token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens") - token_prune.set_defaults(func=cmd_token_prune) - - 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) - - backups = sub.add_parser("backups", help="Backup management") - backups_sub = backups.add_subparsers(title="action", dest="action") - backups_sub.add_parser("list", help="List recorded backups").set_defaults( - func=cmd_backups_list - ) - backups_run = backups_sub.add_parser( - "run", help="Enqueue a backup (processed by the running server)" - ) - backups_run.add_argument( - "target", - choices=["database", "uploads", "keys", "full"], - help="What to back up", - ) - backups_run.set_defaults(func=cmd_backups_run) - backups_sub.add_parser( - "prune", help="Remove backup records whose archive file is missing" - ).set_defaults(func=cmd_backups_prune) - backups_sub.add_parser( - "clear", help="Delete every backup archive and record" - ).set_defaults(func=cmd_backups_clear) - - seo = sub.add_parser("seo", help="SEO Diagnostics job management") - seo_sub = seo.add_subparsers(title="action", dest="action") - seo_prune = seo_sub.add_parser( - "prune", help="Delete expired SEO audit reports and their job rows" - ) - seo_prune.set_defaults(func=cmd_seo_prune) - seo_clear = seo_sub.add_parser( - "clear", help="Delete every SEO audit report and job row" - ) - seo_clear.set_defaults(func=cmd_seo_clear) - - seo_meta = sub.add_parser("seo-meta", help="SEO metadata job management") - seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action") - seo_meta_prune = seo_meta_sub.add_parser( - "prune", help="Delete expired SEO metadata job rows (generated metadata persists)" - ) - seo_meta_prune.set_defaults(func=cmd_seo_meta_prune) - seo_meta_clear = seo_meta_sub.add_parser( - "clear", help="Delete every SEO metadata job row (generated metadata persists)" - ) - seo_meta_clear.set_defaults(func=cmd_seo_meta_clear) - - deepsearch = sub.add_parser("deepsearch", help="DeepSearch job management") - deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action") - deepsearch_prune = deepsearch_sub.add_parser( - "prune", help="Delete expired DeepSearch sessions and their job rows" - ) - deepsearch_prune.set_defaults(func=cmd_deepsearch_prune) - deepsearch_clear = deepsearch_sub.add_parser( - "clear", help="Delete every DeepSearch session and job row" - ) - deepsearch_clear.set_defaults(func=cmd_deepsearch_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) - - sub.add_parser( - "emoji-sync", - help="Regenerate static/js/emoji-shortcodes.js from the emoji library", - ).set_defaults(func=cmd_emoji_sync) - - migrate = sub.add_parser( - "migrate-data", - help="Relocate legacy runtime files into the consolidated data/ directory", - ) - migrate.add_argument( - "--dry-run", - action="store_true", - help="Print the source-to-destination plan without changing anything", - ) - migrate.set_defaults(func=cmd_migrate_data) - - args = parser.parse_args() - if hasattr(args, "func"): - args.func(args) - else: - parser.print_help() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/devplacepy/cli/__init__.py b/devplacepy/cli/__init__.py new file mode 100644 index 00000000..d94f6e73 --- /dev/null +++ b/devplacepy/cli/__init__.py @@ -0,0 +1,83 @@ +# retoor + +from devplacepy.cli.main import main, build_parser +from devplacepy.cli._shared import _audit_cli +from devplacepy.cli.roles import cmd_role_get, cmd_role_set +from devplacepy.cli.apikeys import cmd_apikey_get, cmd_apikey_reset, cmd_apikey_backfill +from devplacepy.cli.tokens import ( + cmd_token_issue, + cmd_token_list, + cmd_token_revoke, + cmd_token_revoke_all, + cmd_token_prune, +) +from devplacepy.cli.devii import cmd_devii_reset_quota +from devplacepy.cli.news import cmd_news_clear, cmd_news_sanitize +from devplacepy.cli.attachments import cmd_attachments_prune +from devplacepy.cli.jobs import ( + cmd_zips_prune, + cmd_zips_clear, + cmd_forks_prune, + cmd_forks_clear, + cmd_seo_prune, + cmd_seo_clear, + cmd_seo_meta_prune, + cmd_seo_meta_clear, + cmd_deepsearch_prune, + cmd_deepsearch_clear, +) +from devplacepy.cli.backups import ( + cmd_backups_list, + cmd_backups_run, + cmd_backups_prune, + cmd_backups_clear, +) +from devplacepy.cli.containers import ( + cmd_containers_list, + cmd_containers_reconcile, + cmd_containers_prune, + cmd_containers_prune_builds, + cmd_containers_gc_workspaces, +) +from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data + +__all__ = [ + "main", + "build_parser", + "_audit_cli", + "cmd_role_get", + "cmd_role_set", + "cmd_apikey_get", + "cmd_apikey_reset", + "cmd_apikey_backfill", + "cmd_token_issue", + "cmd_token_list", + "cmd_token_revoke", + "cmd_token_revoke_all", + "cmd_token_prune", + "cmd_devii_reset_quota", + "cmd_news_clear", + "cmd_news_sanitize", + "cmd_attachments_prune", + "cmd_zips_prune", + "cmd_zips_clear", + "cmd_forks_prune", + "cmd_forks_clear", + "cmd_seo_prune", + "cmd_seo_clear", + "cmd_seo_meta_prune", + "cmd_seo_meta_clear", + "cmd_deepsearch_prune", + "cmd_deepsearch_clear", + "cmd_backups_list", + "cmd_backups_run", + "cmd_backups_prune", + "cmd_backups_clear", + "cmd_containers_list", + "cmd_containers_reconcile", + "cmd_containers_prune", + "cmd_containers_prune_builds", + "cmd_containers_gc_workspaces", + "cmd_emoji_sync", + "cmd_migrate_data", +] diff --git a/devplacepy/cli/__main__.py b/devplacepy/cli/__main__.py new file mode 100644 index 00000000..9132e2a9 --- /dev/null +++ b/devplacepy/cli/__main__.py @@ -0,0 +1,6 @@ +# retoor + +from devplacepy.cli.main import main + +if __name__ == "__main__": + main() diff --git a/devplacepy/cli/_shared.py b/devplacepy/cli/_shared.py new file mode 100644 index 00000000..5643427f --- /dev/null +++ b/devplacepy/cli/_shared.py @@ -0,0 +1,18 @@ +# retoor + + +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, + ) diff --git a/devplacepy/cli/apikeys.py b/devplacepy/cli/apikeys.py new file mode 100644 index 00000000..6ade00d4 --- /dev/null +++ b/devplacepy/cli/apikeys.py @@ -0,0 +1,65 @@ +# retoor + +import sys +from devplacepy.database import get_table +from devplacepy.cli._shared import _audit_cli + + +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 register_apikeys(subparsers): + apikey = subparsers.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) diff --git a/devplacepy/cli/attachments.py b/devplacepy/cli/attachments.py new file mode 100644 index 00000000..4816add3 --- /dev/null +++ b/devplacepy/cli/attachments.py @@ -0,0 +1,43 @@ +# retoor + +from devplacepy.cli._shared import _audit_cli + + +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 register_attachments(subparsers): + attachments = subparsers.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) diff --git a/devplacepy/cli/backups.py b/devplacepy/cli/backups.py new file mode 100644 index 00000000..26dde613 --- /dev/null +++ b/devplacepy/cli/backups.py @@ -0,0 +1,82 @@ +# retoor + +import sys +from devplacepy.cli._shared import _audit_cli + + +def cmd_backups_list(args): + from devplacepy.services.backup import store + + backups = store.list_backups() + if not backups: + print("No backups recorded") + return + for backup in backups: + size = store.human_bytes(int(backup.get("size_bytes") or 0)) + print( + f"{backup['uid']} {backup.get('target', ''):<9} " + f"{backup.get('status', ''):<8} {size:>10} " + f"{backup.get('created_at', '')} {backup.get('filename', '')}" + ) + + +def cmd_backups_run(args): + from devplacepy.services.backup import store + from devplacepy.services.jobs import queue + + if not store.is_valid_target(args.target): + print(f"Unknown target '{args.target}'. Choose one of: {', '.join(store.BACKUP_TARGETS)}") + sys.exit(1) + job_uid = queue.enqueue( + "backup", + {"target": args.target, "schedule_uid": "", "created_by": "cli"}, + owner_kind="system", + owner_id="cli", + preferred_name=f"{store.target_label(args.target)} (cli)", + ) + store.create_backup(target=args.target, created_by="cli", job_uid=job_uid) + _audit_cli( + "cli.backups.run", + f"CLI enqueued {args.target} backup", + metadata={"target": args.target, "job_uid": job_uid}, + ) + print(f"Enqueued {args.target} backup job {job_uid} (processed by the running server)") + + +def cmd_backups_prune(args): + from devplacepy.services.backup import store + + removed = store.prune_orphans() + _audit_cli("cli.backups.prune", f"CLI pruned {removed} orphan backups", metadata={"count": removed}) + print(f"Pruned {removed} orphan backup record(s)") + + +def cmd_backups_clear(args): + from devplacepy.services.backup import store + + removed = store.clear_all() + _audit_cli("cli.backups.clear", f"CLI cleared all backups ({removed})", metadata={"count": removed}) + print(f"Cleared {removed} backup(s) and their archives") + + +def register_backups(subparsers): + backups = subparsers.add_parser("backups", help="Backup management") + backups_sub = backups.add_subparsers(title="action", dest="action") + backups_sub.add_parser("list", help="List recorded backups").set_defaults( + func=cmd_backups_list + ) + backups_run = backups_sub.add_parser( + "run", help="Enqueue a backup (processed by the running server)" + ) + backups_run.add_argument( + "target", + choices=["database", "uploads", "keys", "full"], + help="What to back up", + ) + backups_run.set_defaults(func=cmd_backups_run) + backups_sub.add_parser( + "prune", help="Remove backup records whose archive file is missing" + ).set_defaults(func=cmd_backups_prune) + backups_sub.add_parser( + "clear", help="Delete every backup archive and record" + ).set_defaults(func=cmd_backups_clear) diff --git a/devplacepy/cli/containers.py b/devplacepy/cli/containers.py new file mode 100644 index 00000000..765d49f6 --- /dev/null +++ b/devplacepy/cli/containers.py @@ -0,0 +1,107 @@ +# retoor + +from devplacepy.cli._shared import _audit_cli + + +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 register_containers(subparsers): + containers = subparsers.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) diff --git a/devplacepy/cli/devii.py b/devplacepy/cli/devii.py new file mode 100644 index 00000000..3c30122a --- /dev/null +++ b/devplacepy/cli/devii.py @@ -0,0 +1,66 @@ +# retoor + +import sys +from devplacepy.database import get_table +from devplacepy.cli._shared import _audit_cli + + +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 register_devii(subparsers): + devii = subparsers.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) diff --git a/devplacepy/cli/jobs.py b/devplacepy/cli/jobs.py new file mode 100644 index 00000000..c328ae65 --- /dev/null +++ b/devplacepy/cli/jobs.py @@ -0,0 +1,267 @@ +# retoor + +from devplacepy.database import get_table +from devplacepy.cli._shared import _audit_cli + + +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 _remove_seo_artifacts(job): + import shutil + from devplacepy.config import SEO_REPORTS_DIR + + shutil.rmtree(SEO_REPORTS_DIR / job["uid"], ignore_errors=True) + + +def cmd_seo_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="seo", 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_seo_artifacts(job) + get_table("jobs").delete(uid=job["uid"]) + removed += 1 + _audit_cli("cli.seo.prune", f"CLI pruned {removed} expired SEO jobs", metadata={"count": removed}) + print(f"Pruned {removed} expired SEO audit(s)") + + +def cmd_seo_clear(args): + from devplacepy.services.jobs import queue + + jobs = queue.list_jobs(kind="seo") + for job in jobs: + _remove_seo_artifacts(job) + get_table("jobs").delete(uid=job["uid"]) + _audit_cli("cli.seo.clear", f"CLI cleared all SEO jobs ({len(jobs)})", metadata={"count": len(jobs)}) + print(f"Cleared {len(jobs)} SEO audit(s) and their reports") + + +def cmd_seo_meta_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="seo_meta", 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.seo_meta.prune", + f"CLI pruned {removed} expired SEO metadata jobs", + metadata={"count": removed}, + ) + print(f"Pruned {removed} expired SEO metadata job(s)") + + +def cmd_seo_meta_clear(args): + from devplacepy.services.jobs import queue + + jobs = queue.list_jobs(kind="seo_meta") + for job in jobs: + get_table("jobs").delete(uid=job["uid"]) + _audit_cli( + "cli.seo_meta.clear", + f"CLI cleared all SEO metadata jobs ({len(jobs)})", + metadata={"count": len(jobs)}, + ) + print(f"Cleared {len(jobs)} SEO metadata job(s); generated metadata persists") + + +def _remove_deepsearch_artifacts(job): + import shutil + from devplacepy.config import DEEPSEARCH_DIR + from devplacepy.services.deepsearch.store import VectorStore + + uid = job["uid"] + collection = f"ds_{uid.replace('-', '')}" + VectorStore(collection).drop() + shutil.rmtree(DEEPSEARCH_DIR / uid, ignore_errors=True) + + +def cmd_deepsearch_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="deepsearch", 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_deepsearch_artifacts(job) + get_table("jobs").delete(uid=job["uid"]) + removed += 1 + _audit_cli( + "cli.deepsearch.prune", + f"CLI pruned {removed} expired DeepSearch jobs", + metadata={"count": removed}, + ) + print(f"Pruned {removed} expired DeepSearch job(s)") + + +def cmd_deepsearch_clear(args): + from devplacepy.services.jobs import queue + + jobs = queue.list_jobs(kind="deepsearch") + for job in jobs: + _remove_deepsearch_artifacts(job) + get_table("jobs").delete(uid=job["uid"]) + _audit_cli( + "cli.deepsearch.clear", + f"CLI cleared all DeepSearch jobs ({len(jobs)})", + metadata={"count": len(jobs)}, + ) + print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections") + + +def register_jobs(subparsers): + zips = subparsers.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 = subparsers.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) + + seo = subparsers.add_parser("seo", help="SEO Diagnostics job management") + seo_sub = seo.add_subparsers(title="action", dest="action") + seo_prune = seo_sub.add_parser( + "prune", help="Delete expired SEO audit reports and their job rows" + ) + seo_prune.set_defaults(func=cmd_seo_prune) + seo_clear = seo_sub.add_parser( + "clear", help="Delete every SEO audit report and job row" + ) + seo_clear.set_defaults(func=cmd_seo_clear) + + seo_meta = subparsers.add_parser("seo-meta", help="SEO metadata job management") + seo_meta_sub = seo_meta.add_subparsers(title="action", dest="action") + seo_meta_prune = seo_meta_sub.add_parser( + "prune", help="Delete expired SEO metadata job rows (generated metadata persists)" + ) + seo_meta_prune.set_defaults(func=cmd_seo_meta_prune) + seo_meta_clear = seo_meta_sub.add_parser( + "clear", help="Delete every SEO metadata job row (generated metadata persists)" + ) + seo_meta_clear.set_defaults(func=cmd_seo_meta_clear) + + deepsearch = subparsers.add_parser("deepsearch", help="DeepSearch job management") + deepsearch_sub = deepsearch.add_subparsers(title="action", dest="action") + deepsearch_prune = deepsearch_sub.add_parser( + "prune", help="Delete expired DeepSearch sessions and their job rows" + ) + deepsearch_prune.set_defaults(func=cmd_deepsearch_prune) + deepsearch_clear = deepsearch_sub.add_parser( + "clear", help="Delete every DeepSearch session and job row" + ) + deepsearch_clear.set_defaults(func=cmd_deepsearch_clear) diff --git a/devplacepy/cli/main.py b/devplacepy/cli/main.py new file mode 100644 index 00000000..9861271c --- /dev/null +++ b/devplacepy/cli/main.py @@ -0,0 +1,46 @@ +# retoor + +import argparse +import sys +from devplacepy.cli.roles import register_roles +from devplacepy.cli.apikeys import register_apikeys +from devplacepy.cli.tokens import register_tokens +from devplacepy.cli.news import register_news +from devplacepy.cli.attachments import register_attachments +from devplacepy.cli.devii import register_devii +from devplacepy.cli.jobs import register_jobs +from devplacepy.cli.backups import register_backups +from devplacepy.cli.containers import register_containers +from devplacepy.cli.migrate import register_migrate + + +def build_parser(): + parser = argparse.ArgumentParser(description="DevPlace admin CLI") + sub = parser.add_subparsers(title="commands", dest="command") + + register_roles(sub) + register_apikeys(sub) + register_tokens(sub) + register_news(sub) + register_attachments(sub) + register_devii(sub) + register_jobs(sub) + register_backups(sub) + register_containers(sub) + register_migrate(sub) + + return parser + + +def main(): + parser = build_parser() + args = parser.parse_args() + if hasattr(args, "func"): + args.func(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/devplacepy/cli/migrate.py b/devplacepy/cli/migrate.py new file mode 100644 index 00000000..404f6190 --- /dev/null +++ b/devplacepy/cli/migrate.py @@ -0,0 +1,233 @@ +# retoor + +import sys +from devplacepy.cli._shared import _audit_cli + + +def _crc32(path): + import zlib + + crc = 0 + with open(path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + crc = zlib.crc32(chunk, crc) + return crc & 0xFFFFFFFF + + +def _migrate_file(source, dest, dry_run, report): + import os + import shutil + + if not source.exists(): + return + if source.resolve() == dest.resolve(): + return + size = source.stat().st_size + if dest.exists(): + if dest.stat().st_size == size and _crc32(dest) == _crc32(source): + report.append(("done", source, dest, size)) + if not dry_run: + source.unlink() + return + report.append(("conflict", source, dest, size)) + return + report.append(("move", source, dest, size)) + if dry_run: + return + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(dest.name + ".migrating") + shutil.copyfile(source, tmp) + with open(tmp, "rb") as handle: + os.fsync(handle.fileno()) + if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source): + tmp.unlink(missing_ok=True) + raise RuntimeError(f"verification failed copying {source} -> {dest}") + os.replace(tmp, dest) + source.unlink() + + +def _prune_empty_dirs(root): + if not root.exists(): + return + for path in sorted(root.rglob("*"), reverse=True): + if path.is_dir(): + try: + path.rmdir() + except OSError: + pass + try: + root.rmdir() + except OSError: + pass + + +def _migrate_tree(source, dest, dry_run, report): + if not source.exists(): + return + if source.resolve() == dest.resolve(): + return + for child in sorted(source.rglob("*")): + if child.is_file(): + _migrate_file(child, dest / child.relative_to(source), dry_run, report) + if not dry_run: + _prune_empty_dirs(source) + + +def _db_is_locked(path): + import sqlite3 + + try: + conn = sqlite3.connect(str(path), timeout=0.5) + try: + conn.execute("BEGIN IMMEDIATE") + conn.rollback() + return False + finally: + conn.close() + except sqlite3.OperationalError: + return True + + +def _checkpoint(path): + import sqlite3 + + conn = sqlite3.connect(str(path), timeout=5) + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.commit() + finally: + conn.close() + + +def _migrate_db(source, dest, dry_run, report): + if not source.exists(): + return + if source.resolve() == dest.resolve(): + return + if _db_is_locked(source): + raise RuntimeError( + f"{source} is locked - stop the app before running migrate-data" + ) + if not dry_run: + _checkpoint(source) + _migrate_file(source, dest, dry_run, report) + for suffix in ("-wal", "-shm"): + _migrate_file( + source.with_name(source.name + suffix), + dest.with_name(dest.name + suffix), + dry_run, + report, + ) + + +def cmd_emoji_sync(args): + from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module + + count = write_emoji_module() + _audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count}) + print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}") + + +def cmd_migrate_data(args): + import os + from pathlib import Path + from collections import Counter + from devplacepy import config + + base = config.BASE_DIR + home = Path.home() + dry = args.dry_run + report = [] + + config.ensure_data_dirs() + + db_items = [] + if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}": + db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db")) + else: + print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.") + if not os.environ.get("DEVII_TASKS_DB"): + db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB)) + if not os.environ.get("DEVII_LESSONS_DB"): + db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB)) + + file_items = [ + (base / name, config.KEYS_DIR / name) + for name in ( + "notification-private.pem", + "notification-private.pkcs8.pem", + "notification-public.pem", + ) + ] + registry_dest = config.BOT_DIR / "article_registry.json" + registry_sources = [ + path + for path in ( + home / ".dpbot_article_registry.json", + base / ".dpbot_article_registry.json", + ) + if path.exists() + ] + registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True) + if registry_sources: + file_items.append((registry_sources[0], registry_dest)) + for stale in registry_sources[1:]: + print(f"Leaving older duplicate registry untouched: {stale}") + + legacy_var = base / "var" + tree_items = [ + (base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR), + (home / ".devplace_bots", config.BOT_DIR), + ] + for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"): + tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name])) + + try: + for source, dest in db_items: + _migrate_db(source, dest, dry, report) + for source, dest in file_items: + _migrate_file(source, dest, dry, report) + for source, dest in tree_items: + _migrate_tree(source, dest, dry, report) + except RuntimeError as exc: + print(f"ERROR: {exc}") + sys.exit(1) + + if not report: + print("Nothing to migrate; the data directory is already consolidated.") + return + for status, source, dest, size in report: + print(f" [{status}] {source} -> {dest} ({size} bytes)") + counts = Counter(status for status, *_ in report) + print() + print( + ("Planned: " if dry else "Migrated: ") + + ", ".join(f"{count} {status}" for status, count in sorted(counts.items())) + ) + if any(status == "conflict" for status, *_ in report): + print( + "Conflicts left both source and destination untouched; resolve them by hand." + ) + if dry: + print("Dry run - nothing changed. Re-run without --dry-run to apply.") + + +def register_migrate(subparsers): + subparsers.add_parser( + "emoji-sync", + help="Regenerate static/js/emoji-shortcodes.js from the emoji library", + ).set_defaults(func=cmd_emoji_sync) + + migrate = subparsers.add_parser( + "migrate-data", + help="Relocate legacy runtime files into the consolidated data/ directory", + ) + migrate.add_argument( + "--dry-run", + action="store_true", + help="Print the source-to-destination plan without changing anything", + ) + migrate.set_defaults(func=cmd_migrate_data) diff --git a/devplacepy/cli/news.py b/devplacepy/cli/news.py new file mode 100644 index 00000000..b197f48b --- /dev/null +++ b/devplacepy/cli/news.py @@ -0,0 +1,53 @@ +# retoor + +from devplacepy.utils import strip_html +from devplacepy.cli._shared import _audit_cli + + +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 register_news(subparsers): + news = subparsers.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) diff --git a/devplacepy/cli/roles.py b/devplacepy/cli/roles.py new file mode 100644 index 00000000..a50f1683 --- /dev/null +++ b/devplacepy/cli/roles.py @@ -0,0 +1,57 @@ +# retoor + +import sys +from devplacepy.database import get_table, invalidate_admins_cache +from devplacepy.cli._shared import _audit_cli + + +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"]) + invalidate_admins_cache() + 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 register_roles(subparsers): + role = subparsers.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) diff --git a/devplacepy/cli/tokens.py b/devplacepy/cli/tokens.py new file mode 100644 index 00000000..288337a6 --- /dev/null +++ b/devplacepy/cli/tokens.py @@ -0,0 +1,125 @@ +# retoor + +import sys +from devplacepy.database import get_table +from devplacepy.cli._shared import _audit_cli + + +def cmd_token_issue(args): + from devplacepy.services.access_tokens import issue_token + + users = get_table("users") + user = users.find_one(username=args.username) + if not user: + print(f"User '{args.username}' not found") + sys.exit(1) + + result = issue_token(user, label=args.label or "cli") + _audit_cli( + "cli.token.issue", + f"CLI issued access token for {args.username}", + target_type="user", + target_uid=user["uid"], + target_label=args.username, + metadata={"token_uid": result["uid"]}, + ) + print(result["access_token"]) + + +def cmd_token_list(args): + from datetime import datetime, timezone + + users = get_table("users") + user = users.find_one(username=args.username) + if not user: + print(f"User '{args.username}' not found") + sys.exit(1) + + tokens = get_table("access_tokens") + now = datetime.now(timezone.utc) + found = False + for t in tokens.find(user_uid=user["uid"], deleted_at=None): + found = True + expires_at = t.get("expires_at", "") + try: + expires = datetime.fromisoformat(expires_at) + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + status = "expired" if expires < now else "active" + except (ValueError, TypeError): + status = "unknown" + label = t.get("label", "") or "-" + print( + f" uid={t['uid']} token={t['token'][:12]}... " + f"label={label} expires={expires_at} status={status}" + ) + if not found: + print(f"No active tokens for '{args.username}'") + + +def cmd_token_revoke(args): + from devplacepy.services.access_tokens import revoke_token + + ok = revoke_token(args.token_uid) + if not ok: + print(f"Token uid='{args.token_uid}' not found or already revoked") + sys.exit(1) + _audit_cli( + "cli.token.revoke", + f"CLI revoked access token uid={args.token_uid}", + metadata={"token_uid": args.token_uid}, + ) + print(f"Revoked token uid='{args.token_uid}'") + + +def cmd_token_revoke_all(args): + from devplacepy.services.access_tokens import revoke_all + + users = get_table("users") + user = users.find_one(username=args.username) + if not user: + print(f"User '{args.username}' not found") + sys.exit(1) + + count = revoke_all(user["uid"]) + _audit_cli( + "cli.token.revoke_all", + f"CLI revoked all access tokens for {args.username}", + target_type="user", + target_uid=user["uid"], + target_label=args.username, + metadata={"count": count}, + ) + print(f"Revoked {count} token(s) for '{args.username}'") + + +def cmd_token_prune(args): + from devplacepy.services.access_tokens import prune_expired + + count = prune_expired() + _audit_cli( + "cli.token.prune", + f"CLI pruned {count} expired access tokens", + metadata={"count": count}, + ) + print(f"Pruned {count} expired token(s)") + + +def register_tokens(subparsers): + token = subparsers.add_parser("token", help="Manage DevPlace access tokens") + token_sub = token.add_subparsers(title="action", dest="action") + token_issue = token_sub.add_parser("issue", help="Issue an access token for a user") + token_issue.add_argument("username") + token_issue.add_argument("--label", default="cli", help="Optional label for the token") + token_issue.set_defaults(func=cmd_token_issue) + token_list = token_sub.add_parser("list", help="List a user's active access tokens") + token_list.add_argument("username") + token_list.set_defaults(func=cmd_token_list) + token_revoke = token_sub.add_parser("revoke", help="Revoke a single access token by uid") + token_revoke.add_argument("token_uid") + token_revoke.set_defaults(func=cmd_token_revoke) + token_revoke_all = token_sub.add_parser("revoke-all", help="Revoke all access tokens for a user") + token_revoke_all.add_argument("username") + token_revoke_all.set_defaults(func=cmd_token_revoke_all) + token_prune = token_sub.add_parser("prune", help="Soft-delete all expired access tokens") + token_prune.set_defaults(func=cmd_token_prune)