diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 12:32:24 +0200 |
| commit | 988a0eecd522431980174910db28e45a63ff2710 (patch) | |
| tree | 80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/src/meshbay_cli/commands | |
| parent | e0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff) | |
| download | meshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz | |
feat(cli): account, rm -r and mkdir, Tab completion
account shows the account and does what the sign-up form and the Profile
page do: register (the passphrase floor and the hub's proof of work checked
here, the recovery key printed once), verify, change the passphrase, reset a
forgotten one, change the e-mail address, sign out everywhere, delete. A new
passphrase carries the copy of the account's keys each node keeps for
browsers first — opened under the old M, the same keys sealed under the new
one — and only then tells the hub; a refusal sets the copies back. A reset
restores them from the recovery copy. The connections that do it never make
keys nor ask for a device approval. This machine keeps the M it changed away
from, so a node out of reach is carried at its next connection; a copy under
an M it does not know is no longer rewritten, which locked the browsers out
after a passphrase changed elsewhere.
rm -r removes a folder and all it holds, from the node's operator key: the
files, then the folders deepest first, one confirmation that counts them.
mkdir makes folders, --parents the missing ones (not -p, the passphrase).
completion bash|powershell prints the script that makes Tab complete actions,
verbs, options and the paths of the groups this machine is signed in to,
cached two minutes; the deb and rpm packages install the bash one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
4 files changed, 770 insertions, 22 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py new file mode 100644 index 0000000..2137995 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py @@ -0,0 +1,488 @@ +""" +`account`: the account itself — see it, create one, verify its address, change +or reset the passphrase, change the address, end every session, erase it. + +What the application's sign-up form and Profile page do, with the same rules +(docs/MESHBAY_DESIGN.md §3.1, §3.6). The passphrase never leaves this machine: +the hub is shown the key derived from it, and the floor a new one must clear +is checked here, as the page checks it. A new passphrase carries the account's +keys on every node across first, and only then is the hub told — so a change +that cannot be carried leaves the account as it was. +""" + +import asyncio +import getpass +import json +import os +import sys +import time +from pathlib import Path + +from ..client.account import Account, NoPassphrase +from ..client.groups import my_groups +from ..client.hub import HubError +from ..client.keys import ( + auth_key, + b64, + bundle_master_key, + generate_recovery, + passphrase_half, + recovery_bytes, + recovery_key, + unb64, +) +from ..client.passphrase import passphrase_problem, solve_captcha +from ..client.rewrap import Carried, carry +from ..client.store import FILE_KEY_NOTICE, KEY_FILE +from ..errors import AUTH, NOT_FOUND, REFUSED, USAGE, CliError +from ..output import confirm, err, safe, sentence +from .context import Context + +HELP = ("your account: show it, register, verify, change or reset the passphrase, " + "change the e-mail address, sign out everywhere, delete it") +VERBS = ("show", "register", "verify", "passphrase", "reset", "email", + "sign-out-everywhere", "delete") +USERNAME_MIN = 8 # the hub's USERNAME_MIN_LEN +# How many earlier `M` this machine keeps, to carry copies on nodes that were +# out of reach when the passphrase changed. +KEEP_MASTERS = 5 + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("account", parents=[parent], help=HELP, description=sentence(HELP) + + ". With no verb, shows the account.") + p.add_argument("verb", nargs="?", default="show", choices=VERBS, + help="what to do; show when left out") + p.add_argument("arg", nargs="?", metavar="CODE|ADDRESS", + help="verify: the code e-mailed at registration; email: the new " + "address") + p.add_argument("--email", metavar="ADDRESS", + help="register, verify, reset: the account's e-mail address") + p.add_argument("--code", help="reset: the code e-mailed for it; email: the code sent " + "to the new address") + p.add_argument("--new-password-file", metavar="FILE", + help="passphrase: read the new passphrase from FILE's first line " + "(- for stdin); also MESHBAY_NEW_PASSWORD") + p.add_argument("--recovery-key-file", metavar="FILE", + help="reset: read the recovery key from FILE, to restore your keys " + "on the nodes") + p.add_argument("--email-recovery-key", action="store_true", + help="register: also send the recovery key with the verification " + "e-mail") + p.add_argument("--no-remember", action="store_true", + help="reset: sign in afterwards without storing the session") + p.add_argument("-y", "--yes", action="store_true", + help="delete without asking for confirmation") + + +async def run(ctx: Context) -> int: + a = ctx.args + await ctx.hub.check_version() + verb = a.verb + if verb == "register": + return await _register(ctx) + if verb == "verify": + return await _verify(ctx) + if verb == "reset": + return await _reset(ctx) + account = ctx.account() + try: + if verb == "show": + return await _show(ctx, account) + if verb == "passphrase": + return await _change_passphrase(ctx, account) + if verb == "email": + return await _email(ctx, account) + if verb == "sign-out-everywhere": + return await _sign_out_everywhere(ctx, account) + return await _delete(ctx, account) + finally: + await account.close() + + +# ── Reading what a person types ────────────────────────────────────────────── + +def _ask(prompt: str, given: str | None, what: str) -> str: + if given: + return given.strip() + if not sys.stdin.isatty(): + raise CliError(USAGE, f"Name the {what}: there is no terminal to ask on.") + value = input(prompt).strip() + if not value: + raise CliError(USAGE, f"No {what} given.") + return value + + +def _read_first_line(path: str) -> str: + try: + text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8") + except OSError as e: + raise CliError(USAGE, f"Cannot read {path}: {e.strerror}") from e + return text.splitlines()[0] if text else "" + + +def _new_passphrase(given: str | None) -> str: + """A new passphrase: `given`, else typed twice at a terminal; held to the floor.""" + if given is None: + if not sys.stdin.isatty(): + raise NoPassphrase("A new passphrase is needed and there is no terminal to " + "ask on.") + given = getpass.getpass("New passphrase: ") + if problem := passphrase_problem(given): + raise CliError(USAGE, problem) + if getpass.getpass("The same again: ") != given: + raise CliError(USAGE, "The two passphrases differ; nothing was changed.") + if problem := passphrase_problem(given): + raise CliError(USAGE, problem) + return given + + +def _given_passphrase(ctx: Context) -> str | None: + """The global sources of a passphrase, without prompting.""" + a = ctx.args + if a.password is not None: + return a.password + if a.password_file: + return _read_first_line(a.password_file) + return os.environ.get("MESHBAY_PASSWORD") + + +async def _captcha(ctx: Context) -> str | None: + challenge = await ctx.hub.captcha() + if challenge is None: + return None + token = await asyncio.to_thread(solve_captcha, challenge) + if token is None: + raise CliError(REFUSED, "The hub's proof of work has no answer; try again.") + return token + + +def _hub_refusal(e: HubError, what: str) -> CliError: + if e.status == 429: + return CliError(REFUSED, f"{what}: too many attempts — {e.detail}") + if e.status in (401, 403): + return CliError(AUTH, f"{what}: {e.detail}") + if e.status == 404: + return CliError(NOT_FOUND, f"{what}: {e.detail}") + return CliError(REFUSED, f"{what}: {e.detail}") + + +def _say(ctx: Context, data: dict, text: str) -> None: + if ctx.args.json: + print(json.dumps(data, ensure_ascii=False)) + elif not ctx.args.quiet: + print(text) + + +# ── show ───────────────────────────────────────────────────────────────────── + +async def _show(ctx: Context, account: Account) -> int: + me = await account.call(account.hub.me) + if ctx.args.json: + print(json.dumps({"hub": ctx.hub.url, **{k: me.get(k) for k in ( + "user_id", "username", "email", "role", "status")}}, ensure_ascii=False)) + return 0 + print(f"Account: {safe(me.get('username', ''))}\n" + f"Hub: {ctx.hub.url}\n" + f"E-mail: {safe(me.get('email', ''))}\n" + f"Status: {safe(me.get('status', ''))}") + if me.get("role") and me["role"] != "user": + print(f"Role: {safe(me['role'])}") + return 0 + + +# ── register, verify ───────────────────────────────────────────────────────── + +async def _register(ctx: Context) -> int: + a = ctx.args + username = _ask("Username: ", ctx.username, "account name (--user)") + if len(username) < USERNAME_MIN or len(username) > 64 or \ + not username.replace("_", "").replace("-", "").replace(".", "").isalnum(): + raise CliError(USAGE, f"A username is {USERNAME_MIN} to 64 letters, digits, " + "-, _ or .") + email = _ask("E-mail address: ", a.email, "e-mail address (--email)") + passphrase = _new_passphrase(_given_passphrase(ctx)) + ak = await asyncio.to_thread(auth_key, username, passphrase) + raw, mnemonic = generate_recovery() + try: + try: + reg = await ctx.hub.register(username, email, ak, + mnemonic if a.email_recovery_key else None, + await _captcha(ctx)) + except HubError as e: + if e.detail not in ("captcha_failed", "captcha_required"): + raise + # An answer is spent once checked; a fresh one, once. + reg = await ctx.hub.register(username, email, ak, + mnemonic if a.email_recovery_key else None, + await _captcha(ctx)) + except HubError as e: + raise _hub_refusal(e, "Not registered") from e + + if ctx.args.json: + print(json.dumps({"user_id": reg.get("user_id"), "username": username, + "recovery_key": mnemonic, + "recovery_key_emailed": bool(a.email_recovery_key), + "email_verification_required": True})) + else: + print(f"Account {safe(username)} created on {ctx.hub.url}.\n\n" + "Your recovery key — shown this once, kept nowhere by MeshBay" + + (" (it is also in the verification e-mail)" if a.email_recovery_key + else "") + ":\n\n" + f" {mnemonic}\n\n" + "Keep it somewhere safe, apart from your passphrase. If you forget the " + "passphrase, it is what gives you your groups back.") + del raw + if not sys.stdin.isatty() or ctx.args.json: + err(f"A code was sent to {safe(email)}. Then: meshbay-cli account verify CODE " + f"--email {safe(email)}") + return 0 + code = input(f"\nThe code sent to {email} (Enter to do it later): ").strip() + if not code: + err(f"Later: meshbay-cli account verify CODE --email {safe(email)}") + return 0 + await _verify_code(ctx, email, code) + return 0 + + +async def _verify(ctx: Context) -> int: + a = ctx.args + code = _ask("The code: ", a.arg or a.code, "code") + email = _ask("E-mail address: ", a.email, "e-mail address (--email)") + await _verify_code(ctx, email, code) + return 0 + + +async def _verify_code(ctx: Context, email: str, code: str) -> None: + try: + await ctx.hub.verify_email(email, code) + except HubError as e: + raise _hub_refusal(e, "Not verified") from e + _say(ctx, {"verified": True, "email": email}, + "Verified. Sign in with: meshbay-cli login") + + +# ── passphrase: change a known one ─────────────────────────────────────────── + +async def _change_passphrase(ctx: Context, account: Account) -> int: + a = ctx.args + old = ctx.passphrase() + given = (_read_first_line(a.new_password_file) if a.new_password_file + else os.environ.get("MESHBAY_NEW_PASSWORD")) + new = _new_passphrase(given) + if new == old: + raise CliError(USAGE, "The new passphrase is the current one; nothing was changed.") + me = await account.call(account.hub.me) + if int(me.get("passphrase_locked_for") or 0) > 0: + raise CliError(AUTH, "Sign-in is locked for this account for " + f"{max(1, int(me['passphrase_locked_for']) // 60)} min; " + "nothing was changed.") + user, uid = account.username, me["user_id"] + old_ak = await asyncio.to_thread(auth_key, user, old) + try: + pep = await account.call(account.hub.bundle_pepper, old_ak) + except HubError as e: + if e.status == 403: + raise CliError(AUTH, "The current passphrase does not match; nothing was " + "changed.") from e + raise _hub_refusal(e, "Not changed") from e + pepper, version = unb64(pep["bundle_pepper"]), int(pep.get("bundle_pepper_version") or 1) + old_a = await asyncio.to_thread(passphrase_half, user, old) + new_a = await asyncio.to_thread(passphrase_half, user, new) + old_m, new_m = (bundle_master_key(old_a, pepper, uid), + bundle_master_key(new_a, pepper, uid)) + # What opens the copies as they are: the current passphrase's keys. + await account.update(lambda st, se: se.update( + master=b64(old_m), legacy=b64(old_a), pepper_version=version)) + + groups = await my_groups(account) + progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}...")) + if progress: + err("Sealing your keys on each node under the new passphrase:") + carried = await carry(account, groups, master=new_m, pepper_version=version, + progress=progress) + new_ak = await asyncio.to_thread(auth_key, user, new) + try: + r = await account.call(account.hub.change_passphrase, old_ak, new_ak) + except (HubError, CliError) as e: + # The hub kept the old passphrase: the copies go back under it, opened + # with the new one they are sealed under now. + await account.update(lambda st, se: se.update(master=b64(new_m), legacy=b64(new_a))) + try: + back = await carry(account, groups, master=old_m, pepper_version=version, + only=set(carried.done)) + finally: + await account.update(lambda st, se: se.update(master=b64(old_m), + legacy=b64(old_a))) + detail = getattr(e, "detail", None) or getattr(e, "message", str(e)) + lost = len(carried.done) - len(back.done) + raise CliError(REFUSED, f"The hub refused the new passphrase ({detail}); " + "nothing was changed" + ( + f", but {lost} node(s) could not be set back and " + "hold your keys under the new one" if lost else "") + + ".") from e + + def commit(state: dict, secrets: dict) -> None: + kept = [m for m in secrets.get("previous_masters", []) if m != b64(old_m)] + secrets.update(master=b64(new_m), legacy=b64(new_a), pepper_version=version, + previous_masters=([b64(old_m)] + kept)[:KEEP_MASTERS], + access_token=r["access_token"], refresh_token=r["refresh_token"]) + secrets["access_exp"] = time.time() + int(r.get("expires_in", 0)) + await account.update(commit) + _report(ctx, carried, "Passphrase changed", changed=True) + return 0 + + +def _report(ctx: Context, carried: Carried, head: str, *, changed: bool) -> None: + if ctx.args.json: + print(json.dumps({"changed": changed, "nodes_sealed": len(carried.done), + "unreachable": carried.unreachable, "failed": carried.failed})) + return + print(f"{head}. Your keys were sealed again on {len(carried.done)} node" + f"{'s' if len(carried.done) != 1 else ''}.") + if carried.stragglers: + err("Not reached — " + ("this machine carries them over the next time it " + "connects there:" if changed else + "these groups may need their operator (`member unpin`, " + "then a fresh code):")) + for s in carried.stragglers: + err(f" {safe(s)}") + + +# ── reset: a forgotten passphrase ──────────────────────────────────────────── + +async def _reset(ctx: Context) -> int: + a = ctx.args + username = _ask("Username: ", ctx.username or ctx.stored_username, + "account name (--user)") + code = a.code + if not code: + email = _ask("The account's e-mail address: ", a.email, "e-mail address (--email)") + try: + await ctx.hub.reset_request(username, email, await _captcha(ctx)) + except HubError as e: + raise _hub_refusal(e, "No code sent") from e + err(f"If {safe(username)} and {safe(email)} are an account's, a code is on its " + "way there (valid one hour).") + if not sys.stdin.isatty(): + err(f"Then: meshbay-cli account reset -u {safe(username)} --code CODE") + return 0 + code = _ask("The code: ", None, "code") + passphrase = _new_passphrase(_given_passphrase(ctx)) + recovery = None + if a.recovery_key_file: + text = (sys.stdin.read() if a.recovery_key_file == "-" + else Path(a.recovery_key_file).read_text(encoding="utf-8")) + recovery = text.strip() + elif sys.stdin.isatty(): + recovery = input("Your recovery key (Enter if you have none): ").strip() + rkey = None + if recovery: + try: + rkey = recovery_key(recovery_bytes(recovery), username) + except ValueError as e: + raise CliError(USAGE, f"{sentence(str(e))}; nothing was changed.") from e + + new_ak = await asyncio.to_thread(auth_key, username, passphrase) + try: + await ctx.hub.reset_passphrase(username, code.strip(), new_ak) + except HubError as e: + raise _hub_refusal(e, "Not reset") from e + + # Signed in afresh, as `login` does: the reset ended every session and + # dropped every device key on the hub. + remember = not a.no_remember + account = Account(ctx.hub, username, lambda: passphrase) + stored = ctx.store.peek() + if stored.get("hub") == ctx.hub.url and stored.get("username") == username: + try: + ctx.store.load() + account.state, account.secrets = ctx.store.state, ctx.store.secrets + except CliError: + pass + try: + await account.sign_in(remember=remember) + if remember: + if await account.remember(ctx.store) == KEY_FILE: + err(FILE_KEY_NOTICE) + progress = None if a.quiet or a.json else (lambda g: err(f" {safe(g)}...")) + if progress: + err("Restoring your keys on each node:") + carried = await carry(account, await my_groups(account), master=account.master, + pepper_version=account.pepper_version, recovery=rkey, + own_keys=account.browser_access == "on", progress=progress) + finally: + await account.close() + _report(ctx, carried, "Passphrase reset, and signed in", changed=False) + if not rkey and not ctx.args.json: + err("Without the recovery key, groups whose keys this machine does not keep " + "need their operator (`member unpin`, then a fresh code).") + return 0 + + +# ── email, sign-out-everywhere, delete ─────────────────────────────────────── + +async def _email(ctx: Context, account: Account) -> int: + a = ctx.args + if a.code: + try: + r = await account.call(account.hub.verify_email_change, a.code.strip()) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + _say(ctx, {"email": r.get("email")}, f"E-mail address changed to " + f"{safe(r.get('email', ''))}.") + return 0 + address = _ask("New e-mail address: ", a.arg, "new address") + ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase()) + try: + await account.call(account.hub.change_email, address, ak) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + if ctx.args.json or not sys.stdin.isatty(): + _say(ctx, {"email_verification_required": True, "email": address}, + f"A code was sent to {safe(address)}. Then: meshbay-cli account email " + "--code CODE") + return 0 + code = input(f"The code sent to {address}: ").strip() + if not code: + err("Later: meshbay-cli account email --code CODE") + return 0 + try: + r = await account.call(account.hub.verify_email_change, code) + except HubError as e: + raise _hub_refusal(e, "Not changed") from e + print(f"E-mail address changed to {safe(r.get('email', ''))}.") + return 0 + + +async def _sign_out_everywhere(ctx: Context, account: Account) -> int: + r = await account.call(account.hub.revoke_sessions) + n = int(r.get("revoked") or 0) + _say(ctx, {"revoked": n}, + f"Signed out everywhere: {n} session{'s' if n != 1 else ''} ended. Devices " + "with a key of their own (this machine, if it is remembered) sign back in with " + "it; remove one from your devices to stop it.") + return 0 + + +async def _delete(ctx: Context, account: Account) -> int: + a = ctx.args + confirm(f"Erase the account {account.username} on {ctx.hub.url}, and every group " + "it owns? This cannot be undone.", a.yes) + ak = await asyncio.to_thread(auth_key, account.username, ctx.passphrase()) + try: + r = await account.call(account.hub.delete_account, ak) + except HubError as e: + if e.status == 403: + raise CliError(AUTH, "The passphrase does not match; nothing was erased.") from e + raise _hub_refusal(e, "Not erased") from e + if account.remembered: + with ctx.store.locked(): + ctx.store.erase() + gone = r.get("groups_deleted") or [] + _say(ctx, {"deleted": True, "groups_deleted": gone}, + f"Account {safe(account.username)} erased" + + (f", with {len(gone)} group{'s' if len(gone) != 1 else ''} it owned" + if gone else "") + ".") + return 0 + diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py new file mode 100644 index 0000000..831c012 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py @@ -0,0 +1,96 @@ +""" +`completion`: the script that makes Tab complete meshbay-cli in a shell. + +bash: eval "$(meshbay-cli completion bash)" (in ~/.bashrc) +PowerShell: meshbay-cli completion powershell | Out-String | Invoke-Expression + (in $PROFILE) + +The packages install the bash one where bash-completion finds it. Both scripts +ask `meshbay-cli __complete` for the candidates (see `complete.py`): actions, +verbs, options, and the remote paths of the groups this machine is signed in to. +""" + +from ..errors import USAGE, CliError +from ..output import sentence +from .context import Context + +HELP = "print the script that makes Tab complete meshbay-cli in bash or PowerShell" + +BASH = r'''# meshbay-cli completion for bash: eval "$(meshbay-cli completion bash)" +_meshbay_cli() { + local cur=${COMP_WORDS[COMP_CWORD]} line files=0 + local -a found=() + while IFS= read -r line; do + if [[ $line == __files__ ]]; then files=1; else found+=("$line"); fi + done < <(command meshbay-cli __complete bash "$COMP_CWORD" "${COMP_WORDS[@]}" 2>/dev/null) + COMPREPLY=("${found[@]}") + if (( files )); then + local f + while IFS= read -r f; do COMPREPLY+=("$f"); done < <(compgen -f -- "$cur") + fi + # Quote spaces and the like as for file names; no space after a folder. + compopt -o filenames 2>/dev/null + local c + for c in "${COMPREPLY[@]}"; do + if [[ $c == */ ]]; then compopt -o nospace 2>/dev/null; break; fi + done +} +complete -F _meshbay_cli meshbay-cli +''' + +POWERSHELL = r'''# meshbay-cli completion for PowerShell: +# meshbay-cli completion powershell | Out-String | Invoke-Expression +Register-ArgumentCompleter -Native -CommandName 'meshbay-cli', 'meshbay-cli.exe' -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $elements = @($commandAst.CommandElements) + $words = @($elements | ForEach-Object { $_.Extent.Text }) + $index = $words.Count + for ($i = 0; $i -lt $elements.Count; $i++) { + $e = $elements[$i].Extent + if ($cursorPosition -ge $e.StartOffset -and $cursorPosition -le $e.EndOffset) { + $index = $i + } + } + $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + $quote = { + param($s) + if ($s -match '[\s''"`$&|;(){}@,#]') { "'" + ($s -replace "'", "''") + "'" } else { $s } + } + $item = [System.Management.Automation.CompletionResult] + $results = @() + foreach ($c in $found) { + if ($c -eq '__files__') { + $sep = [IO.Path]::DirectorySeparatorChar + $typed = $wordToComplete.Trim([char[]]"'`"") + $dir = Split-Path -Path $typed -Parent + Get-ChildItem -Path "$typed*" -ErrorAction SilentlyContinue | + ForEach-Object { + $p = if ($dir) { Join-Path $dir $_.Name } else { $_.Name } + if ($_.PSIsContainer) { $p += $sep } + $results += $item::new((& $quote $p), $p, 'ProviderItem', $p) + } + continue + } + $results += $item::new((& $quote $c), $c, 'ParameterValue', $c) + } + $results +} +''' + +SCRIPTS = {"bash": BASH, "powershell": POWERSHELL} + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("completion", parents=[parent], help=HELP, description=sentence(HELP) + + '. bash: eval "$(meshbay-cli completion bash)" in ~/.bashrc; ' + "PowerShell: meshbay-cli completion powershell | Out-String | " + "Invoke-Expression in $PROFILE.") + p.add_argument("shell", choices=tuple(SCRIPTS), help="the shell") + + +async def run(ctx: Context) -> int: + script = SCRIPTS.get(ctx.args.shell) + if script is None: + raise CliError(USAGE, "bash or powershell.") + print(script, end="") + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py new file mode 100644 index 0000000..de8cdcb --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py @@ -0,0 +1,108 @@ +""" +`mkdir`: make folders in a group, as the Files tab's "New folder" does. + +Any member may, where the root accepts uploads: organising where files go is +not privileged on the node (removing a folder is — see `rm -r`). The parent +must exist, unless --parents makes the missing ones too; with it a folder +already there is not an error. Not -p, which is the passphrase everywhere in +this program. Names follow the node's rule for uploads, checked here +first so nothing is sent that it would refuse. +""" + +import json + +from ..client.groups import my_groups +from ..client.names import same_name, upload_name_problem +from ..client.paths import handle, parse, resolve_group +from ..client.pool import Pool +from ..client.transfers import TransferError, create_dir +from ..client.tree import Tree +from ..errors import NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError +from ..output import err, safe, sentence +from .context import Context + +HELP = "make folders in a group" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("mkdir", parents=[parent], help=HELP, description=sentence(HELP) + + ", where its root accepts uploads.") + p.add_argument("paths", nargs="+", metavar="PATH", + help="group[@owner]/root/.../new-folder") + p.add_argument("--parents", action="store_true", + help="make missing parent folders too; a folder already there is " + "not an error (not -p: that is --password)") + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + pool = Pool(account) + try: + return await _run(ctx, account, pool) + finally: + await pool.close() + await account.close() + + +async def _run(ctx: Context, account, pool: Pool) -> int: + a = ctx.args + groups = await my_groups(account) + plans = [] # (session, group, [(parent, name), ...]) + for text in a.paths: + remote = parse(text) + if remote is None or len(remote.parts) < 2: + raise CliError(USAGE, "Name the folder to make inside a root: " + "group[@owner]/root/folder") + group = resolve_group(groups, remote.group) + session = await pool.session(group) + tree = Tree.from_index(await session.index()) + shown = f"{handle(group)}/{remote.inner}" + root = tree.roots.get(remote.parts[0]) + if root is None: + raise CliError(NOT_FOUND, f"No root {safe(remote.parts[0])} in " + f"{safe(handle(group))}.") + if not root.get("available", True): + raise CliError(REFUSED, f"{safe(shown)}: its drive is disconnected from the node.") + if not root.get("writable"): + raise CliError(REFUSED, f"{safe(shown)}: the root is read-only — its operator " + "does not accept uploads there.") + steps = [] + for depth in range(2, len(remote.parts) + 1): + parent, name = "/".join(remote.parts[:depth - 1]), remote.parts[depth - 1] + if (why := upload_name_problem(name, is_dir=True)): + raise CliError(REFUSED, f"{safe(name)}: {why}") + path = f"{parent}/{name}" + if tree.is_dir(path): + continue + # Folders are told apart without case, as the node does. + clash = next((i for i in tree.children(parent) if same_name(i.name, name)), + None) if tree.is_dir(parent) else None + if clash is not None: + raise CliError(REFUSED, f"{safe(handle(group))}/{safe(clash.path)} is already " + "there" + ("" if clash.is_dir else " (a file)") + ".") + if depth < len(remote.parts) and not a.parents: + raise CliError(NOT_FOUND, f"No folder {safe(handle(group))}/{safe(path)} " + "(--parents makes it too).") + steps.append((parent, name)) + if not steps and not a.parents: + raise CliError(REFUSED, f"{safe(shown)} is already there.") + plans.append((session, group, steps)) + + made, failed = [], [] + for session, group, steps in plans: + for parent, name in steps: + try: + path = await create_dir(session, parent, name) + except TransferError as e: + failed.append(f"{handle(group)}/{parent}/{name}: {e.message}") + break # the folders below it cannot follow + made.append(f"{handle(group)}/{path}") + if a.json: + print(json.dumps({"made": made, "failed": failed}, ensure_ascii=False)) + elif not a.quiet: + for m in made: + err(f"Made {safe(m)}/") + for f in failed: + err(f"meshbay-cli: {safe(f)}") + return PARTIAL if failed else 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py index 6f5e869..7f4a5c6 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/rm.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/rm.py @@ -1,12 +1,18 @@ """ -`rm`: delete files from groups. Asked first; `--yes` answers for a script. +`rm`: delete files from groups, and with -r folders and all they hold. Asked +first; `--yes` answers for a script. Paths and patterns are resolved against each group's index, as `get` resolves them, and the whole run is checked before anything is asked or sent: a file this account may not delete — not uploaded by it, and this machine's key not -the node's operator — refuses the run (exit 8), as a folder does. The node -checks again on every file; one it refuses is reported and the others go on -(exit 4). +the node's operator — refuses the run (exit 8), as a folder does without -r. + +With -r, a folder is everything below it: its files first, then its folders, +deepest first. The node removes only empty folders, and only for its operator's +key, so -r on a folder is the operator's, and the confirmation counts every +file before one is deleted. A group's root is never removed. The node checks +again on every file and folder; one it refuses is reported and the others go +on (exit 4) — a folder still holding a file it refused stays. """ import json @@ -14,13 +20,14 @@ import json from ..client.groups import my_groups from ..client.paths import handle, parse, resolve_group from ..client.pool import Pool -from ..client.remove import delete_file, may_delete +from ..client.remove import delete_dir, delete_file, may_delete from ..client.tree import Tree from ..errors import NODE, NOT_FOUND, PARTIAL, REFUSED, USAGE, CliError -from ..output import confirm, err, safe, sentence +from ..output import confirm, err, human_size, safe, sentence from .context import Context -HELP = "delete files you uploaded (or any, from the node's operator key)" +HELP = ("delete files you uploaded (or any, from the node's operator key); with -r, " + "folders too, from the operator's key") # How many names the confirmation shows before it counts the rest. SHOWN = 10 @@ -32,6 +39,8 @@ def add_parser(sub, parent) -> None: "quote them. Being the group's owner is not enough: the node " "lets its operator's key, and the uploader's devices, delete.") p.add_argument("paths", nargs="+", metavar="PATH", help="group[@owner]/root/.../file") + p.add_argument("-r", "-R", dest="recursive", action="store_true", + help="folders, with everything in them (the node's operator only)") p.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation") @@ -51,7 +60,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int: groups = await my_groups(account) trees: dict[str, Tree] = {} targets = [] # (session, entry, shown) - missing, folders, not_yours = [], [], [] + dirs = [] # (session, path, shown) + missing, folders, not_yours, roots, not_operator = [], [], [], [], [] for text in a.paths: remote = parse(text) if remote is None or not remote.parts: @@ -69,30 +79,65 @@ async def _run(ctx: Context, account, pool: Pool) -> int: for path in found: shown = f"{handle(group)}/{path}" entry = tree.file(path) - if entry is None: + if entry is not None: + if not may_delete(session, entry, account.user_id): + not_yours.append(shown) + elif all(e["id"] != entry["id"] for _, e, _ in targets): + targets.append((session, entry, shown)) + continue + if not a.recursive: folders.append(shown) - elif not may_delete(session, entry, account.user_id): - not_yours.append(shown) - elif all(e["id"] != entry["id"] for _, e, _ in targets): - targets.append((session, entry, shown)) + elif "/" not in path: + roots.append(shown) + elif session.role != "operator": + not_operator.append(shown) + elif not tree.root_available(path): + raise CliError(REFUSED, f"{safe(shown)} is on a drive the node does not see " + "now; nothing was deleted.") + else: + for folder, kids in tree.walk(path): + if all(d[1] != folder or d[0] is not session for d in dirs): + dirs.append((session, folder, f"{handle(group)}/{folder}")) + for kid in kids: + if not kid.is_dir and all(e["id"] != kid.entry["id"] + for _, e, _ in targets): + targets.append((session, kid.entry, + f"{handle(group)}/{kid.path}")) if missing: raise CliError(NOT_FOUND, "Nothing in your groups matches: " + ", ".join(safe(m) for m in missing)) if folders: - raise CliError(USAGE, "rm deletes files; these are folders: " - + ", ".join(safe(f) for f in folders)) + raise CliError(USAGE, "These are folders; -r deletes them with everything in " + "them: " + ", ".join(safe(f) for f in folders)) + if roots: + raise CliError(REFUSED, "A group's root is not deleted here — its operator removes " + "it from the node: " + ", ".join(safe(r) for r in roots)) + if not_operator: + raise CliError(REFUSED, "Not deleted — removing a folder is the node operator's, " + "and this machine's key is not:\n " + + "\n ".join(safe(n) for n in not_operator)) if not_yours: raise CliError(REFUSED, "Not deleted — you did not upload these, and this machine's " "key is not the node's operator:\n " + "\n ".join(safe(n) for n in not_yours)) names = [shown for _, _, shown in targets] - listed = "\n ".join(safe(n) for n in names[:SHOWN]) - more = f"\n ... and {len(names) - SHOWN} more" if len(names) > SHOWN else "" - confirm(f"Delete {len(names)} file{'s' if len(names) != 1 else ''} for every member " - f"of the group?\n {listed}{more}\n", a.yes) + # The deepest first: each one is empty by the time it is asked for. + dirs.sort(key=lambda d: d[1].count("/"), reverse=True) + tops = [shown for _, path, shown in dirs + if not any(path.startswith(other + "/") for _, other, _ in dirs)] + shown_names = tops + [n for n in names if not any(n.startswith(t + "/") for t in tops)] + listed = "\n ".join(safe(n) + ("/" if n in tops else "") for n in shown_names[:SHOWN]) + more = (f"\n ... and {len(shown_names) - SHOWN} more" + if len(shown_names) > SHOWN else "") + size = sum(int(e.get("size", 0)) for _, e, _ in targets) + what = f"{len(names)} file{'s' if len(names) != 1 else ''}" + if dirs: + what += (f" ({human_size(size)}) and {len(dirs)} folder" + f"{'s' if len(dirs) != 1 else ''}") + confirm(f"Delete {what} for every member of the group?\n {listed}{more}\n", a.yes) - deleted, failed = [], [] + deleted, removed, failed = [], [], [] for session, entry, shown in targets: try: await delete_file(session, entry["id"]) @@ -101,11 +146,22 @@ async def _run(ctx: Context, account, pool: Pool) -> int: if e.code not in (NODE, REFUSED): raise failed.append(f"{shown}: {e.message}") + for session, path, shown in dirs: + try: + await delete_dir(session, path) + removed.append(shown) + except CliError as e: + if e.code not in (NODE, REFUSED): + raise + failed.append(f"{shown}/: {e.message}") if a.json: - print(json.dumps({"deleted": deleted, "failed": failed}, ensure_ascii=False)) + print(json.dumps({"deleted": deleted, "folders_removed": removed, "failed": failed}, + ensure_ascii=False)) elif not a.quiet: n = len(deleted) - err(f"Deleted {n} file{'s' if n != 1 else ''}.") + err(f"Deleted {n} file{'s' if n != 1 else ''}" + + (f" and {len(removed)} folder{'s' if len(removed) != 1 else ''}" + if dirs else "") + ".") for f in failed: err(f"meshbay-cli: {safe(f)}") return PARTIAL if failed else 0 |