diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 22:20:15 +0200 |
| commit | 422647e7898b8df67cd11f8fb060aec23dec84aa (patch) | |
| tree | 7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/commands/ls.py | |
| parent | 28c88908b88c58cba4de08710f6bb268ede02d57 (diff) | |
| download | meshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz | |
feat(cli): meshbay-cli, a command-line client for members
Signs in to the hub, lists the account's groups and their folders, and
downloads and uploads files directly with each group's node, end-to-end
encrypted. With no argument it opens a two-pane interactive mode (Textual).
Session and device keys are kept encrypted under the system's key storage,
or a 0600 key file where there is none; node keys are pinned at first
contact; a new machine links as a device.
Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a
man page generated from the parser) and frozen next to meshbay-node in the
full Windows installer, not the light one, with a self-test the build runs.
client/keys.py ports keyderive.js and is LGPL like it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands/ls.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/ls.py | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/ls.py b/packages/meshbay-cli/src/meshbay_cli/commands/ls.py new file mode 100644 index 0000000..bdff3c0 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/ls.py @@ -0,0 +1,208 @@ +""" +`ls`: your groups, or what a folder of a group holds โ as the Files tab shows it. + +With no path the hub answers alone (`/v1/groups/mine`) and no node is dialled. +Each group is its own top-level folder and nothing is merged across groups +(docs/MESHBAY_DESIGN.md ยง9.11). +""" + +import asyncio +import json +import sys + +from ..client.groups import my_groups +from ..client.paths import encode_name, handle, parse, resolve_group +from ..client.pool import Pool +from ..client.tree import Item, Tree +from ..errors import NODE, NOT_FOUND, PARTIAL, CliError +from ..output import columns, date, err, human_size, is_tty, safe, sentence +from .context import Context + +HELP = "list your groups, or a folder of a group" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("ls", parents=[parent], help=HELP, description=sentence(HELP) + ".") + p.add_argument("path", nargs="?", default="", + help="group[@owner]/root/folder; none lists your groups") + p.add_argument("-l", dest="long", action="store_true", + help="long form: type, size, date added") + p.add_argument("-R", dest="recursive", action="store_true", help="recurse into folders") + p.add_argument("-a", dest="all", action="store_true", + help="include the content of unavailable roots") + p.add_argument("--sort", choices=("name", "size", "date"), default="name", + help="the order within folders and files (default: name)") + p.add_argument("-r", dest="reverse", action="store_true", help="reverse the order") + + +def _sorted(items: list[Item], key: str, reverse: bool) -> list[Item]: + """Folders first, as the Files tab; the order applies within each.""" + def k(i: Item): + if key == "size": + return (i.size, i.name.lower()) + if key == "date": + return (i.added_at, i.name.lower()) + return (i.name.lower(), i.name) + dirs = sorted((i for i in items if i.is_dir), key=k, reverse=reverse) + files = sorted((i for i in items if not i.is_dir), key=k, reverse=reverse) + return dirs + files + + +def _flags(item: Item) -> str: + flags = [] + if item.root is not None: + flags.append("writable" if item.root.get("writable") else "read-only") + if not item.available: + flags.append("unavailable") + return f" ({', '.join(flags)})" if flags else "" + + +def _line(item: Item, long: bool) -> str: + name = safe(item.name) + ("/" if item.is_dir else "") + if not long: + return name + kind = "root" if item.root is not None else ("dir" if item.is_dir else "file") + size = "" if item.is_dir else human_size(item.size) + return f"{kind:<5}{size:>10} {date(item.added_at):<16} {name}{_flags(item)}" + + +def _print_items(items: list[Item], long: bool) -> None: + if long or not is_tty(): + for i in items: + print(_line(i, long)) + elif items: + print(columns([_line(i, False) for i in items])) + + +def _json_item(group: dict, item: Item) -> dict: + base = handle(group) + out = {"group": group["name"], "owner": group.get("owner_username"), + "path": f"{base}/{'/'.join(encode_name(p) for p in item.path.split('/'))}", + "name": item.name, "available": item.available, + # `kind`, not `type`: an index entry's `type` is its media type. + "kind": "root" if item.root is not None else ("dir" if item.is_dir else "file")} + if item.root is not None: + out["writable"] = bool(item.root.get("writable")) + if item.entry is not None: + out.update({k: v for k, v in item.entry.items() if k not in ("name", "path")}) + return out + + +async def _list_groups(ctx: Context, account) -> int: + groups = sorted(await my_groups(account), key=lambda g: (g["name"].lower(), + g.get("owner_username") or "")) + if ctx.args.json: + if ctx.args.recursive: + return await _every_group(ctx, account, groups) + json.dump([{"group": g["name"], "owner": g.get("owner_username"), "id": g["id"], + "path": handle(g), "online": bool(g.get("node_online")), + "visibility": g.get("visibility"), "admin": bool(g.get("is_admin"))} + for g in groups], sys.stdout, indent=1) + print() + return 0 + if ctx.args.recursive: + return await _every_group(ctx, account, groups) + if ctx.args.long or not is_tty(): + for g in groups: + state = "up" if g.get("node_online") else "down" + print(f"{state:<6}{safe(handle(g))}" if ctx.args.long else safe(handle(g))) + else: + print(columns([safe(handle(g)) for g in groups])) + return 0 + + +async def _every_group(ctx: Context, account, groups: list[dict]) -> int: + """ + Every group's tree. All are dialled at once through the pool, so a node that + does not answer costs its own deadline and nobody else's; a group whose node + the hub reports as down is not dialled at all. Printed in name order. + """ + pool = Pool(account) + + async def tree_of(group: dict): + if not group.get("node_online"): + raise CliError(NODE, "no node serving it is online") + session = await pool.session(group) + return Tree.from_index(await session.index()) + + try: + results = await asyncio.gather(*(tree_of(g) for g in groups), return_exceptions=True) + finally: + await pool.close() + failed = 0 + listing_json = [] + first = True + for group, result in zip(groups, results): + if isinstance(result, BaseException): + if not isinstance(result, CliError): + raise result + failed += 1 + err(f"meshbay-cli: {safe(handle(group))}: {result.message}") + continue + if ctx.args.json: + listing_json += _json_listing(ctx, group, result, "") + continue + if not first: + print() + first = False + _list_in_group(ctx, group, result, "") + if ctx.args.json: + json.dump(listing_json, sys.stdout, indent=1) + print() + return PARTIAL if failed else 0 + + +async def run(ctx: Context) -> int: + await ctx.hub.check_version() + account = ctx.account() + try: + remote = parse(ctx.args.path) + if remote is None: + return await _list_groups(ctx, account) + group = resolve_group(await my_groups(account), remote.group) + pool = Pool(account) + try: + tree = Tree.from_index(await (await pool.session(group)).index()) + finally: + await pool.close() + return _list_in_group(ctx, group, tree, remote.inner) + finally: + await account.close() + + +def _list_in_group(ctx: Context, group: dict, tree: Tree, path: str) -> int: + a = ctx.args + if path and not tree.is_dir(path): + entry = tree.file(path) + if entry is None: + raise CliError(NOT_FOUND, f"No file or folder {path!r} in {handle(group)}.") + parent = path.rpartition("/")[0] + listing = [(None, [Item(entry["name"], path, False, entry=entry, + available=tree.root_available(parent))])] + elif a.recursive: + listing = list(tree.walk(path, include_unavailable=a.all)) + else: + listing = [(path, tree.children(path, include_unavailable=a.all))] + + if a.json: + json.dump([_json_item(group, i) for _, items in listing for i in items], + sys.stdout, indent=1) + print() + return 0 + _print_listing(a, group, listing) + return 0 + + +def _json_listing(ctx: Context, group: dict, tree: Tree, path: str) -> list[dict]: + return [_json_item(group, i) + for _, items in tree.walk(path, include_unavailable=ctx.args.all) for i in items] + + +def _print_listing(a, group: dict, listing) -> None: + for n, (folder, items) in enumerate(listing): + if a.recursive: + if n: + print() + shown = "/".join(encode_name(p) for p in folder.split("/")) if folder else "" + print(safe(f"{handle(group)}/{shown}".rstrip("/")) + ":") + _print_items(_sorted(items, a.sort, a.reverse), a.long) |