Files
cli-anything-codimd/cli_anything/codimd/codimd_cli.py
Timmy 8575d7631f Add admin reset-password command
Features:
- Add admin reset-password command to reset user passwords
- Support SQLite, PostgreSQL, and MySQL databases
- Use scrypt hashing compatible with CodiMD
- Update README with reset-password usage examples
2026-04-07 12:27:23 +08:00

839 lines
25 KiB
Python

#!/usr/bin/env python3
"""
CodiMD CLI - Main entry point.
A stateful CLI for CodiMD collaborative markdown notes.
"""
import sys
import json
import click
import traceback
from pathlib import Path
from typing import Optional
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from cli_anything.codimd.core import (
Config, Session, CodiMDClient,
NoteManager, UserManager, ExportManager, RevisionManager
)
from cli_anything.codimd.utils import output_json, format_error, format_success
# Global context
ctx = {"json_mode": False}
def handle_error(func):
"""Error handler wrapper for CLI commands."""
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except PermissionError as e:
if ctx["json_mode"]:
output_json(format_error(str(e)))
else:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except FileNotFoundError as e:
if ctx["json_mode"]:
output_json(format_error(str(e)))
else:
click.echo(f"Not found: {e}", err=True)
sys.exit(1)
except Exception as e:
if ctx["json_mode"]:
output_json(format_error(f"{type(e).__name__}: {e}"))
else:
click.echo(f"Error: {e}", err=True)
if "--debug" in sys.argv:
traceback.print_exc()
sys.exit(1)
return wrapper
def get_client() -> CodiMDClient:
"""Get authenticated client."""
config = Config()
session = Session(config.config_dir)
client = CodiMDClient(
server=config.server,
session_cookies=session.get_cookies(),
timeout=config.timeout,
verify_ssl=config.verify_ssl
)
return client, session
@click.group(invoke_without_command=True)
@click.option("--json", is_flag=True, help="Output in JSON format")
@click.option("--server", help="CodiMD server URL")
@click.option("--version", is_flag=True, help="Show version")
@click.pass_context
def cli(ctx, json, server, version):
"""CodiMD CLI - Collaborative markdown notes."""
if version:
click.echo("CodiMD CLI v0.1.0")
return
# Set JSON mode
globals()["ctx"]["json_mode"] = json
# Set server if provided
if server:
config = Config()
config.server = server
# Show help if no command
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@cli.group()
def note():
"""Note operations."""
pass
@cli.group()
def user():
"""User operations."""
pass
@cli.group()
def export():
"""Export operations."""
pass
@cli.group()
def revision():
"""Revision operations."""
pass
@cli.group()
def config():
"""Configuration commands."""
pass
@cli.group()
def admin():
"""Admin operations (require database access)."""
pass
# Note commands
@note.command("list")
@click.option("--limit", type=int, help="Limit number of results")
@handle_error
def list_notes(limit):
"""List user's notes."""
client, session = get_client()
if not session.is_authenticated():
raise PermissionError("Not authenticated. Please login first.")
manager = NoteManager(client)
notes = manager.list_my_notes()
if limit:
notes = notes[:limit]
if ctx["json_mode"]:
output_json(format_success({"notes": notes, "count": len(notes)}))
else:
if not notes:
click.echo("No notes found.")
else:
for n in notes:
note_id = n.get('id', 'unknown')
# Decode Unicode escape sequences in text
text = n.get('text', 'Untitled')
# Handle both escaped unicode and normal text
if '\\u' in text:
try:
import json
text = json.loads(f'"{text}"')
except:
pass
click.echo(f"{note_id} - {text}")
@note.command("get")
@click.argument("note_id")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def get_note(note_id, output):
"""Get note content."""
client, session = get_client()
manager = NoteManager(client)
# For content, we need to get it via the download endpoint
export_manager = ExportManager(client)
content = export_manager.export_markdown(note_id)
if output:
output.write_text(content)
click.echo(f"Note saved to {output}")
else:
click.echo(content)
@note.command("create")
@click.option("--content", "-c", help="Note content")
@click.option("--file", "-f", type=Path, help="Read content from file")
@click.option("--alias", "-a", help="Note alias")
@handle_error
def create_note(content, file, alias):
"""Create a new note."""
client, session = get_client()
note_content = ""
if file:
note_content = file.read_text()
elif content:
note_content = content
manager = NoteManager(client)
result = manager.create_note(note_content, alias)
if ctx["json_mode"]:
output_json(format_success(result))
else:
click.echo(f"Note created: {result.get('url')}")
click.echo(f"ID: {result.get('id')}")
@note.command("update")
@click.argument("note_id")
@click.option("--content", "-c", help="New content")
@click.option("--file", "-f", type=Path, help="Read content from file")
@handle_error
def update_note(note_id, content, file):
"""Update note content."""
client, session = get_client()
note_content = ""
if file:
note_content = file.read_text()
elif content:
note_content = content
else:
raise ValueError("Either --content or --file must be provided")
manager = NoteManager(client)
result = manager.update_note(note_id, note_content)
if ctx["json_mode"]:
output_json(format_success(result))
else:
click.echo(f"Note updated: {note_id}")
@note.command("delete")
@click.argument("note_id")
@click.confirmation_option(prompt="Are you sure you want to delete this note?")
@handle_error
def delete_note(note_id):
"""Delete a note."""
client, session = get_client()
manager = NoteManager(client)
manager.delete_note(note_id)
if ctx["json_mode"]:
output_json(format_success({"deleted": note_id}))
else:
click.echo(f"Note deleted: {note_id}")
@note.command("info")
@click.argument("note_id")
@handle_error
def note_info(note_id):
"""Get note metadata."""
client, session = get_client()
manager = NoteManager(client)
info = manager.get_note_info(note_id)
if ctx["json_mode"]:
output_json(format_success(info))
else:
click.echo(f"Note ID: {note_id}")
for key, value in info.items():
if key != "id":
click.echo(f" {key}: {value}")
@note.command("publish")
@click.argument("note_id")
@handle_error
def publish_note(note_id):
"""Get or create publish link for note."""
client, session = get_client()
manager = NoteManager(client)
result = manager.get_publish_link(note_id)
if ctx["json_mode"]:
output_json(format_success(result))
else:
click.echo(f"Published note: {result.get('url')}")
# User commands
@user.command("status")
@handle_error
def user_status():
"""Check login status."""
config = Config()
session = Session(config.config_dir)
if session.is_authenticated():
user = session.get_user()
status_data = {
"authenticated": True,
"server": config.server,
"user": user
}
if ctx["json_mode"]:
output_json(format_success(status_data))
else:
click.echo(f"✓ Logged in to {config.server}")
if user:
# Get name from either direct field or nested profile
name = user.get("name") or user.get("profile", {}).get("name", "N/A")
click.echo(f" User: {name}")
else:
if ctx["json_mode"]:
output_json(format_success({
"authenticated": False,
"server": config.server
}))
else:
click.echo(f"✗ Not logged in to {config.server}")
click.echo(" Use 'cli-anything-codimd user login' to login")
@user.command("me")
@handle_error
def user_me():
"""Get current user info."""
client, session = get_client()
if not session.is_authenticated():
raise PermissionError("Not authenticated. Please login first.")
manager = UserManager(client)
user = manager.get_me()
if ctx["json_mode"]:
output_json(format_success(user))
else:
from cli_anything.codimd.utils import format_user_info
click.echo(format_user_info(user))
@user.command("export")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def user_export(output):
"""Export all user data."""
client, session = get_client()
if not session.is_authenticated():
raise PermissionError("Not authenticated. Please login first.")
manager = UserManager(client)
data = manager.export_my_data()
if output:
output.write_text(json.dumps(data, indent=2))
click.echo(f"Data exported to {output}")
elif ctx["json_mode"]:
output_json(format_success(data))
else:
click.echo(json.dumps(data, indent=2))
@user.command("login")
@click.option("--email", help="Email for login")
@click.option("--password", help="Password for login")
@handle_error
def user_login(email, password):
"""Login to CodiMD server."""
config = Config()
session = Session(config.config_dir)
if not email:
email = click.prompt("Email")
if not password:
password = click.prompt("Password", hide_input=True)
# Create client for login
client = CodiMDClient(
server=config.server,
timeout=config.timeout,
verify_ssl=config.verify_ssl
)
# Perform login
manager = UserManager(client)
result = manager.login(email, password)
# Store session cookies
session.set_cookies(result["cookies"])
# Store user info if available
if "user" in result:
session.set_user(result["user"])
user_data = result["user"]
if ctx["json_mode"]:
output_json(format_success({
"logged_in": True,
"user": user_data
}))
else:
from cli_anything.codimd.utils import format_user_info
click.echo("Login successful!")
click.echo(format_user_info(user_data))
else:
if ctx["json_mode"]:
output_json(format_success({"logged_in": True}))
else:
click.echo("Login successful!")
@user.command("logout")
@handle_error
def user_logout():
"""Logout from current session."""
config = Config()
session = Session(config.config_dir)
session.clear()
if ctx["json_mode"]:
output_json(format_success({"logged_out": True}))
else:
click.echo("Logged out successfully")
@user.command("delete")
@click.option("--token", help="Delete token")
@click.confirmation_option(prompt="Are you sure you want to delete your account?")
@handle_error
def user_delete(token):
"""Delete current user account."""
client, session = get_client()
if not session.is_authenticated():
raise PermissionError("Not authenticated.")
manager = UserManager(client)
manager.delete_user(token)
# Clear local session
session.clear()
if ctx["json_mode"]:
output_json(format_success({"deleted": True}))
else:
click.echo("Account deleted successfully")
# Export commands
@export.command("markdown")
@click.argument("note_id")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def export_markdown(note_id, output):
"""Export note as markdown."""
client, session = get_client()
manager = ExportManager(client)
if output:
manager.export_markdown(note_id, output)
click.echo(f"Exported to {output}")
else:
content = manager.export_markdown(note_id)
click.echo(content)
@export.command("pdf")
@click.argument("note_id")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def export_pdf(note_id, output):
"""Export note as PDF."""
client, session = get_client()
manager = ExportManager(client)
if not output:
output = Path(f"{note_id}.pdf")
manager.export_pdf(note_id, output)
click.echo(f"Exported to {output}")
@export.command("html")
@click.argument("note_id")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def export_html(note_id, output):
"""Export note as HTML."""
client, session = get_client()
manager = ExportManager(client)
if output:
manager.export_html(note_id, output)
click.echo(f"Exported to {output}")
else:
content = manager.export_html(note_id)
click.echo(content)
@export.command("slide")
@click.argument("note_id")
@click.option("--output", "-o", type=Path, help="Output file")
@handle_error
def export_slide(note_id, output):
"""Export note as reveal.js slides."""
client, session = get_client()
manager = ExportManager(client)
if output:
manager.export_slide(note_id, output)
click.echo(f"Exported to {output}")
else:
content = manager.export_slide(note_id)
click.echo(content)
# Revision commands
@revision.command("list")
@click.argument("note_id")
@handle_error
def list_revisions(note_id):
"""List note revisions."""
client, session = get_client()
manager = RevisionManager(client)
revisions = manager.list_revisions(note_id)
if ctx["json_mode"]:
output_json(format_success({"revisions": revisions}))
else:
if not revisions:
click.echo("No revisions found.")
else:
for r in revisions:
from datetime import datetime
ts = r.get("time", 0)
dt = datetime.fromtimestamp(ts / 1000 if ts > 1000000000000 else ts)
click.echo(f"{dt.isoformat()} - Length: {r.get('length', 0)}")
@revision.command("get")
@click.argument("note_id")
@click.argument("timestamp", type=int)
@handle_error
def get_revision(note_id, timestamp):
"""Get note at specific revision time."""
client, session = get_client()
manager = RevisionManager(client)
content = manager.get_revision_at_time(note_id, timestamp)
if ctx["json_mode"]:
output_json(format_success(content))
else:
if isinstance(content, dict):
click.echo(content.get("content", str(content)))
else:
click.echo(content)
# Config commands
@config.command("get")
@click.argument("key", required=False)
@handle_error
def config_get(key):
"""Get configuration value."""
config = Config()
if key:
value = getattr(config, key, None)
if ctx["json_mode"]:
output_json(format_success({key: str(value)}))
else:
click.echo(f"{key}: {value}")
else:
if ctx["json_mode"]:
output_json(format_success({
"server": config.server,
"timeout": config.timeout,
"verify_ssl": config.verify_ssl
}))
else:
click.echo(f"Server: {config.server}")
click.echo(f"Timeout: {config.timeout}")
click.echo(f"Verify SSL: {config.verify_ssl}")
@config.command("set")
@click.argument("key")
@click.argument("value")
@handle_error
def config_set(key, value):
"""Set configuration value."""
config = Config()
if key == "server":
config.server = value
elif key == "timeout":
config.timeout = int(value)
elif key == "verify_ssl":
config.verify_ssl = value.lower() in ("true", "1", "yes")
else:
raise ValueError(f"Unknown config key: {key}")
if ctx["json_mode"]:
output_json(format_success({key: value}))
else:
click.echo(f"Set {key} = {value}")
# Admin commands
@admin.command("list-users")
@click.option("--db-type", type=click.Choice(["sqlite", "postgresql", "mysql"]), default="sqlite", help="Database type")
@click.option("--db-path", type=Path, help="SQLite database path")
@click.option("--db-host", help="Database host (for PostgreSQL/MySQL)")
@click.option("--db-name", help="Database name (for PostgreSQL/MySQL)")
@click.option("--db-user", help="Database user (for PostgreSQL/MySQL)")
@click.option("--db-password", help="Database password (for PostgreSQL/MySQL)")
@click.option("--db-port", type=int, default=5432, help="Database port (default: 5432 for PostgreSQL, 3306 for MySQL)")
@handle_error
def admin_list_users(db_type, db_path, db_host, db_name, db_user, db_password, db_port):
"""List all users in the CodiMD database."""
from cli_anything.codimd.core import CodiMDDatabase
if db_type == "sqlite":
if not db_path:
# Try default CodiMD SQLite path
default_paths = [
"./codimd/db.sqlite",
"./db.sqlite",
"/var/lib/codimd/db.sqlite"
]
for path in default_paths:
if Path(path).exists():
db_path = path
break
if not db_path:
raise ValueError(
"SQLite database path not found. "
"Please specify with --db-path or ensure db.sqlite is in current directory."
)
users = CodiMDDatabase.list_users_sqlite(str(db_path))
elif db_type == "postgresql":
if not all([db_host, db_name, db_user]):
raise ValueError("--db-host, --db-name, and --db-user are required for PostgreSQL")
if not db_password:
db_password = click.prompt("Database password", hide_input=True)
users = CodiMDDatabase.list_users_postgresql(db_host, db_name, db_user, db_password, db_port)
elif db_type == "mysql":
if not all([db_host, db_name, db_user]):
raise ValueError("--db-host, --db-name, and --db-user are required for MySQL")
if not db_password:
db_password = click.prompt("Database password", hide_input=True)
users = CodiMDDatabase.list_users_mysql(db_host, db_name, db_user, db_password, db_port)
if ctx["json_mode"]:
output_json(format_success({"users": users, "count": len(users)}))
else:
if not users:
click.echo("No users found.")
else:
click.echo(f"Total users: {len(users)}")
for u in users:
user_id = u.get("id", "N/A")[:8] + "..." if len(u.get("id", "")) > 8 else u.get("id", "N/A")
email = u.get("email") or "N/A"
# Get name from profile
profile = u.get("profile")
if profile:
if isinstance(profile, str):
try:
profile = json.loads(profile)
except:
profile = {}
name = profile.get("name") or profile.get("displayName") or "N/A"
else:
name = "N/A"
created = u.get("createdAt", "N/A")
click.echo(f" {user_id} | {email:30s} | {name}")
if created != "N/A":
click.echo(f" Created: {created}")
@admin.command("user-notes")
@click.argument("user_id")
@click.option("--db-path", type=Path, default="./db.sqlite", help="SQLite database path")
@handle_error
def admin_user_notes(user_id, db_path):
"""List all notes for a specific user."""
from cli_anything.codimd.core import CodiMDDatabase
if not Path(db_path).exists():
raise FileNotFoundError(f"Database not found: {db_path}")
notes = CodiMDDatabase.list_user_notes_sqlite(str(db_path), user_id)
if ctx["json_mode"]:
output_json(format_success({"user_id": user_id, "notes": notes, "count": len(notes)}))
else:
if not notes:
click.echo(f"No notes found for user {user_id}")
else:
click.echo(f"Notes for user {user_id}: {len(notes)} total")
for n in notes:
note_id = n.get("shortid", n.get("id", "N/A"))
title = n.get("title") or "Untitled"
permission = n.get("permission", "N/A")
click.echo(f" {note_id} | {title[:50]} | [{permission}]")
@admin.command("reset-password")
@click.option("--db-type", type=click.Choice(["sqlite", "postgresql", "mysql"]), default="sqlite", help="Database type")
@click.option("--db-path", type=Path, help="SQLite database path")
@click.option("--db-host", help="Database host (for PostgreSQL/MySQL)")
@click.option("--db-name", help="Database name (for PostgreSQL/MySQL)")
@click.option("--db-user", help="Database user (for PostgreSQL/MySQL)")
@click.option("--db-password", help="Database password (for PostgreSQL/MySQL)")
@click.option("--db-port", type=int, default=5432, help="Database port")
@click.option("--email", help="User email to reset password for")
@click.option("--user-id", help="User ID to reset password for")
@click.option("--password", help="New password (will prompt if not provided)")
@click.confirmation_option(prompt="Are you sure you want to reset this user's password?")
@handle_error
def admin_reset_password(db_type, db_path, db_host, db_name, db_user, db_password, db_port, email, user_id, password):
"""Reset a user's password."""
from cli_anything.codimd.core import CodiMDDatabase
if not email and not user_id:
raise ValueError("Either --email or --user-id must be specified")
if not password:
password = click.prompt("New password", hide_input=True, confirmation_prompt=True)
if db_type == "sqlite":
if not db_path:
# Try default paths
default_paths = [
"./codimd/db.sqlite",
"./db.sqlite",
"/var/lib/codimd/db.sqlite"
]
for path in default_paths:
if Path(path).exists():
db_path = path
break
if not db_path:
raise ValueError("SQLite database path not found. Use --db-path")
if user_id:
CodiMDDatabase.reset_password_sqlite_by_id(str(db_path), user_id, password)
click.echo(f"Password reset for user ID: {user_id}")
else:
CodiMDDatabase.reset_password_sqlite(str(db_path), email, password)
click.echo(f"Password reset for: {email}")
elif db_type == "postgresql":
if not all([db_host, db_name, db_user]):
raise ValueError("--db-host, --db-name, and --db-user are required for PostgreSQL")
if not db_password:
db_password = click.prompt("Database password", hide_input=True)
CodiMDDatabase.reset_password_postgresql(
db_host, db_name, db_user, db_password, db_port, email, password
)
click.echo(f"Password reset for: {email}")
elif db_type == "mysql":
if not all([db_host, db_name, db_user]):
raise ValueError("--db-host, --db-name, and --db-user are required for MySQL")
if not db_password:
db_password = click.prompt("Database password", hide_input=True)
CodiMDDatabase.reset_password_mysql(
db_host, db_name, db_user, db_password, db_port, email, password
)
click.echo(f"Password reset for: {email}")
click.echo("✓ Password has been updated. The user can now log in with the new password.")
# REPL mode
@cli.command("repl")
@handle_error
def repl_mode():
"""Start interactive REPL mode."""
import readline
config = Config()
session = Session(config.config_dir)
client = CodiMDClient(
server=config.server,
session_cookies=session.get_cookies()
)
click.echo(f"CodiMD CLI v0.1.0 - REPL Mode")
click.echo(f"Connected to: {config.server}")
click.echo("Type 'help' for commands, 'exit' to quit")
while True:
try:
cmd = click.prompt("codimd", default="", show_default=False).strip()
if not cmd:
continue
if cmd.lower() in ("exit", "quit"):
break
if cmd.lower() == "help":
click.echo("Available commands:")
click.echo(" notes - List your notes")
click.echo(" me - Show user info")
click.echo(" exit - Exit REPL")
continue
# Handle commands
if cmd.lower() == "notes":
manager = NoteManager(client)
notes = manager.list_my_notes()
for n in notes[:10]:
click.echo(f" {n.get('id')} - {n.get('text', 'Untitled')}")
elif cmd.lower() == "me":
manager = UserManager(client)
user = manager.get_me()
click.echo(f" User: {user.get('profile', {}).get('name', 'N/A')}")
else:
click.echo(f"Unknown command: {cmd}")
except EOFError:
break
except KeyboardInterrupt:
click.echo("\nUse 'exit' to quit")
except Exception as e:
click.echo(f"Error: {e}")
click.echo("Goodbye!")
if __name__ == "__main__":
cli()