aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/account.py488
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/completion.py96
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/mkdir.py108
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/rm.py100
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