feat: publish Hermes Nextcloud calendar MCP under AGPL-3.0
This commit is contained in:
330
src/hermes_caldav_mcp/setup_wizard.py
Normal file
330
src/hermes_caldav_mcp/setup_wizard.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Guided enrollment, entirely local and outside the MCP tool surface."""
|
||||
|
||||
import asyncio
|
||||
import getpass
|
||||
import warnings
|
||||
from dataclasses import replace
|
||||
from urllib.parse import quote
|
||||
|
||||
from . import setup_helpers as ui
|
||||
from . import wallet_protection
|
||||
from .types import ConnectionProfile, ErrorCode, SafeError
|
||||
from .wallets import LABELS
|
||||
|
||||
|
||||
def account_help():
|
||||
print(
|
||||
"\nRecommended: give Hermes its own Nextcloud account.\n"
|
||||
"1. An administrator opens the avatar menu > Accounts (Users on older versions)\n"
|
||||
" > New account. Create a user such as hermes with a strong password.\n"
|
||||
" Do not add it to the admin group. If you cannot create users, ask your administrator.\n"
|
||||
"2. Sign in as that user. Open avatar > Personal settings > Security.\n"
|
||||
" Strongly recommended: enable two-factor authentication and save backup codes safely.\n"
|
||||
" If no second factor is offered, ask the administrator to enable a 2FA provider.\n"
|
||||
"3. Sign in to your main account and open Calendar. Next to each wanted calendar,\n"
|
||||
" open its sharing menu (share icon or ... > Edit/Sharing, depending on version).\n"
|
||||
" Share with the hermes login name and enable editing for create/edit access.\n"
|
||||
" Share only the calendars you want Hermes to access; use a user share, not a public link.\n"
|
||||
"4. Sign back in as hermes. Open Personal settings > Security > Devices & sessions.\n"
|
||||
" Name a new app password 'Hermes calendar' and click Create new app password.\n"
|
||||
" Copy the shown login and app password into this wizard. The password is shown once;\n"
|
||||
" if you lose it, revoke it and create another. You can revoke it here at any time.\n"
|
||||
"2FA protects browser login. The app password works without a second factor, so protect it.\n"
|
||||
"Nextcloud editing permission also allows deletion, though this MCP has no delete tool.\n"
|
||||
"Help: https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html\n"
|
||||
" https://docs.nextcloud.com/server/latest/user_manual/en/user_2fa.html\n"
|
||||
" https://docs.nextcloud.com/server/latest/user_manual/en/groupware/calendar.html\n"
|
||||
" https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html\n"
|
||||
)
|
||||
|
||||
|
||||
def _address(default=None):
|
||||
while True:
|
||||
suffix = f" [{ui.display(default)}]" if default else ""
|
||||
value = input(
|
||||
f"Nextcloud web address{suffix} (? for account help; q to cancel): "
|
||||
).strip()
|
||||
if value == "?":
|
||||
account_help()
|
||||
continue
|
||||
if value.casefold() == "q":
|
||||
raise KeyboardInterrupt
|
||||
try:
|
||||
return ui.normalize_address(value or default or "")
|
||||
except SafeError:
|
||||
print(
|
||||
"Paste your HTTPS Nextcloud site address, including its installation folder if any.\n"
|
||||
"A Calendar page address also works. Do not use a public share, DAV resource,\n"
|
||||
"password in the address, or an address with query parameters. Example: cloud.example.test/nextcloud"
|
||||
)
|
||||
|
||||
|
||||
def _username(default=None):
|
||||
while True:
|
||||
suffix = f" [{ui.display(default)}]" if default else ""
|
||||
value = input(
|
||||
f"Nextcloud login name{suffix} (? for help; q to cancel): "
|
||||
).strip()
|
||||
if value == "?":
|
||||
print(
|
||||
"Use the login name shown with the newly generated app password, not the account's display name."
|
||||
)
|
||||
continue
|
||||
if value.casefold() == "q":
|
||||
raise KeyboardInterrupt
|
||||
value = value or default or ""
|
||||
if (
|
||||
value
|
||||
and len(value) <= 8192
|
||||
and ":" not in value
|
||||
and not any(ord(c) < 32 or ord(c) == 127 for c in value)
|
||||
):
|
||||
return value
|
||||
print("Enter a login name without control characters or a colon.")
|
||||
|
||||
|
||||
def _password():
|
||||
print(
|
||||
"In this Nextcloud account: Personal settings > Security > Devices & sessions >\n"
|
||||
"Create new app password. Use the app password, not your browser login password.\n"
|
||||
"Paste it below; nothing will appear while you type. Ctrl+C cancels."
|
||||
)
|
||||
while True:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", getpass.GetPassWarning)
|
||||
try:
|
||||
password = getpass.getpass("Nextcloud app password (hidden): ")
|
||||
except getpass.GetPassWarning:
|
||||
print(
|
||||
"A hidden password prompt is unavailable. Run setup in a normal interactive terminal."
|
||||
)
|
||||
raise SafeError(ErrorCode.INVALID_INPUT) from None
|
||||
if (
|
||||
password
|
||||
and len(password) <= 8192
|
||||
and not any(ord(c) < 32 or ord(c) == 127 for c in password)
|
||||
):
|
||||
return password
|
||||
print(
|
||||
"Paste a nonempty app password without line breaks, or press Ctrl+C to cancel."
|
||||
)
|
||||
|
||||
|
||||
def _account(zone, ca, previous=None, password_only=False):
|
||||
url = (
|
||||
previous.base_url
|
||||
if password_only
|
||||
else _address(previous.base_url if previous else None)
|
||||
)
|
||||
user = (
|
||||
previous.username
|
||||
if password_only
|
||||
else _username(previous.username if previous else None)
|
||||
)
|
||||
password = _password()
|
||||
# normalization has already validated the URL and preserved the subpath.
|
||||
from .transport import endpoint
|
||||
|
||||
origin, base, _ = endpoint(url)
|
||||
home = base.rstrip("/") + "/remote.php/dav/calendars/" + quote(user, safe="") + "/"
|
||||
return ConnectionProfile(
|
||||
origin + base.rstrip("/"), user, password, home, (), zone, ca
|
||||
)
|
||||
|
||||
|
||||
def _discover(profile, discover):
|
||||
while True:
|
||||
print(
|
||||
"Checking the connection and finding calendars (no events will be read or changed)..."
|
||||
)
|
||||
try:
|
||||
calendars = asyncio.run(discover(profile))
|
||||
if calendars:
|
||||
return profile, calendars
|
||||
print(
|
||||
"No calendars are available. Share a calendar with this account, then check again."
|
||||
)
|
||||
except (SafeError, TimeoutError) as caught:
|
||||
error = (
|
||||
caught
|
||||
if isinstance(caught, SafeError)
|
||||
else SafeError(ErrorCode.NETWORK_ERROR)
|
||||
)
|
||||
print(f"{error.code.value}: {error}")
|
||||
if error.code == ErrorCode.AUTH_FAILED:
|
||||
print(
|
||||
"Check the login name shown with your app password. Use a new app password for this account.\n"
|
||||
"Nextcloud may delay repeated failed logins; wait before retrying."
|
||||
)
|
||||
elif error.code in {
|
||||
ErrorCode.NETWORK_ERROR,
|
||||
ErrorCode.BAD_RESPONSE,
|
||||
ErrorCode.NOT_FOUND,
|
||||
}:
|
||||
print(
|
||||
"Check the direct HTTPS site address and its installation folder. Open it in a browser\n"
|
||||
"to check its certificate. Redirects and invalid certificates are not accepted.\n"
|
||||
"For a private certificate authority, choose the certificate-bundle option below."
|
||||
)
|
||||
elif error.code == ErrorCode.PERMISSION_DENIED:
|
||||
print(
|
||||
"Share the wanted calendars with this Nextcloud account and grant the required access."
|
||||
)
|
||||
action = ui.menu(
|
||||
"Connection recovery",
|
||||
[
|
||||
"Retry after fixing settings in Nextcloud",
|
||||
"Change account/address or app password",
|
||||
"Cancel setup",
|
||||
"Use a private certificate-authority bundle",
|
||||
],
|
||||
)
|
||||
if action == 3:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if action == 2:
|
||||
profile = _account(profile.default_timezone, profile.ca_bundle, profile)
|
||||
elif action == 4:
|
||||
profile = replace(profile, ca_bundle=_ca_prompt())
|
||||
|
||||
|
||||
def _ca_prompt():
|
||||
while True:
|
||||
value = input(
|
||||
"Path to your trusted CA certificate bundle (Enter for system certificates; q to cancel): "
|
||||
).strip()
|
||||
if value.casefold() == "q":
|
||||
raise KeyboardInterrupt
|
||||
try:
|
||||
return ui.validate_ca(value)
|
||||
except SafeError:
|
||||
print(
|
||||
"That file is missing or is not a valid certificate bundle. Ask your server administrator for the CA certificates."
|
||||
)
|
||||
|
||||
|
||||
def prepare_profile(
|
||||
name, choice, store, discover, *, timezone=None, ca_bundle=None, hermes_target=None
|
||||
):
|
||||
print(
|
||||
"\nNextcloud calendar setup\n"
|
||||
"Credentials stay in your selected desktop wallet. Returned calendar details can enter\n"
|
||||
"Hermes conversations and reach its model provider; check Hermes's privacy settings.\n"
|
||||
"Use a dedicated non-admin Nextcloud account with 2FA, and share only wanted calendars.\n"
|
||||
"Type ? at the address prompt for step-by-step account, sharing and app-password help.\n"
|
||||
)
|
||||
verified = wallet_protection.ensure_storage(choice)
|
||||
try:
|
||||
previous = store.load(name)
|
||||
except SafeError as error:
|
||||
if error.code != ErrorCode.CREDENTIALS_MISSING:
|
||||
raise
|
||||
previous = None
|
||||
zone = (
|
||||
timezone
|
||||
or (previous.default_timezone if previous else None)
|
||||
or ui.detect_timezone()
|
||||
)
|
||||
if zone is not None and not ui.valid_timezone(zone):
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if zone:
|
||||
print(
|
||||
f"Detected/default timezone: {ui.display(zone.replace('_', ' '))}. You can change it on the review screen."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Your computer's timezone could not be identified. Find yours by city name."
|
||||
)
|
||||
zone = ui.choose_timezone()
|
||||
ca = ui.validate_ca(ca_bundle)
|
||||
if previous:
|
||||
print(
|
||||
f"Existing account: {ui.display(previous.username)} at {ui.display(previous.base_url)}"
|
||||
)
|
||||
action = ui.menu(
|
||||
"Account settings",
|
||||
[
|
||||
"Use saved account and app password",
|
||||
"Replace app password",
|
||||
"Change account/address",
|
||||
],
|
||||
)
|
||||
if action == 1:
|
||||
profile = replace(
|
||||
previous,
|
||||
default_timezone=timezone or previous.default_timezone,
|
||||
ca_bundle=ca if ca_bundle is not None else previous.ca_bundle,
|
||||
)
|
||||
else:
|
||||
profile = _account(
|
||||
timezone or previous.default_timezone,
|
||||
ca if ca_bundle is not None else previous.ca_bundle,
|
||||
previous,
|
||||
password_only=action == 2,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Nextcloud address: paste the address you use in your browser.\n"
|
||||
"Need an account? Avatar > Accounts/Users > New account (administrator only).\n"
|
||||
"Enable 2FA in that account's Personal settings > Security. From your main account,\n"
|
||||
"open Calendar > the calendar's sharing menu and share with that login, with editing enabled."
|
||||
)
|
||||
profile = _account(zone, ca)
|
||||
profile, calendars = _discover(profile, discover)
|
||||
chosen = ui.choose_calendars(
|
||||
calendars, tuple(c.id for c in previous.calendars) if previous else ()
|
||||
)
|
||||
while True:
|
||||
print(
|
||||
f"\nReview\n Account: {ui.display(profile.username)} at {ui.display(profile.base_url)}\n"
|
||||
f" Wallet: {LABELS[choice.provider]}\n Timezone: {ui.display(profile.default_timezone)}\n"
|
||||
f" Calendars: {', '.join(ui.display(c.name) for c in chosen)}\n"
|
||||
f" Certificates: {'custom trusted bundle' if profile.ca_bundle else 'system certificates'}\n"
|
||||
f" Storage: {'protected format detected or wallet password set by you' if verified else 'protection override selected; storage may be unprotected'}"
|
||||
)
|
||||
print(
|
||||
f" Hermes configuration: {ui.display(hermes_target.path)}"
|
||||
if hermes_target
|
||||
else " Hermes: manual configuration block will be printed"
|
||||
)
|
||||
action = ui.menu(
|
||||
"Finish setup",
|
||||
[
|
||||
"Save and connect to Hermes"
|
||||
if hermes_target
|
||||
else "Save and show Hermes configuration",
|
||||
"Change settings",
|
||||
"Cancel without saving",
|
||||
],
|
||||
)
|
||||
if action == 3:
|
||||
raise SafeError(ErrorCode.INVALID_INPUT)
|
||||
if action == 1:
|
||||
if (
|
||||
verified
|
||||
and wallet_protection.inspect_storage(choice).state == "unprotected"
|
||||
):
|
||||
# A known downgrade since the earlier check requires fresh consent.
|
||||
verified = wallet_protection.ensure_storage(choice)
|
||||
return replace(profile, calendars=chosen), verified
|
||||
change = ui.menu(
|
||||
"Change",
|
||||
[
|
||||
"Timezone (search by city)",
|
||||
"Allowed calendars",
|
||||
"Account/address or app password",
|
||||
"Certificate-authority bundle",
|
||||
"Back to review",
|
||||
],
|
||||
)
|
||||
if change == 1:
|
||||
profile = replace(profile, default_timezone=ui.choose_timezone())
|
||||
elif change == 2:
|
||||
chosen = ui.choose_calendars(calendars, tuple(c.id for c in chosen))
|
||||
elif change in {3, 4}:
|
||||
profile = (
|
||||
_account(profile.default_timezone, profile.ca_bundle, profile)
|
||||
if change == 3
|
||||
else replace(profile, ca_bundle=_ca_prompt())
|
||||
)
|
||||
profile, calendars = _discover(profile, discover)
|
||||
chosen = ui.choose_calendars(calendars, tuple(c.id for c in chosen))
|
||||
Reference in New Issue
Block a user