diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 17:42:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 17:45:41 +0200 |
| commit | 41d03f0a35a95daa7d54e7f29ab924776bedf6e4 (patch) | |
| tree | 2e631013746f913413b9811562b9c9a4734a0e41 /packages/meshbay-cli/src/meshbay_cli/commands | |
| parent | 39cac2878de059992cb09a1ccf09b6bcece99fae (diff) | |
| download | meshbay-41d03f0a35a95daa7d54e7f29ab924776bedf6e4.tar.gz | |
fix(cli): make the command-line client work on Windows
Its suite had only run on Linux. On Windows, against a real hub and node and
from the installed meshbay-cli.exe:
- secrets.enc was written in text mode (os.open without O_BINARY): every 0x0A
of the ciphertext became CR LF and the saved session "does not open with
its key" on the next run.
- isatty() is true for NUL, so a script run with `< NUL` or by the Task
Scheduler was asked questions instead of told to pass --yes;
output.terminal() checks for a real console.
- Ctrl+C waited for the selector loop's next timer, up to half a minute in
`chat --follow`; the loop now points the signal wakeup at its self-pipe,
as the proactor does.
- Right-click copy and paste raised: asyncio cannot start a process on the
selector loop; the clipboard tool runs in a thread.
- Tab completion of remote paths ran on the default (proactor) loop, which
never opens a node, and wrote in the locale encoding; the PowerShell script
now reads it as UTF-8.
- `get -R` into a folder past MAX_PATH died with a traceback; get, put, the
resume file and the interactive mode use the extended form at each disk
call, as the node does.
- `groups` showed a SQLite hub's naive UTC time as local time.
The opt-in end-to-end suite now runs on Windows (16 tests: Chrome found under
Program Files and started with the real environment, VLC as the player, Ctrl+C
through a console of its own, PowerShell 5.1 completion of an accented name).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
9 files changed, 40 insertions, 26 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/account.py b/packages/meshbay-cli/src/meshbay_cli/commands/account.py index 4499f0b..1fbef13 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/account.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/account.py @@ -35,7 +35,7 @@ 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 ..output import confirm, err, safe, sentence, terminal from .context import Context HELP = ("your account: show it, register, verify, change or reset the passphrase, " @@ -102,7 +102,7 @@ async def run(ctx: Context) -> int: def _ask(prompt: str, given: str | None, what: str) -> str: if given: return given.strip() - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(USAGE, f"Name the {what}: there is no terminal to ask on.") value = input(prompt).strip() if not value: @@ -121,7 +121,7 @@ def _read_first_line(path: str) -> str: 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(): + if not terminal(sys.stdin): raise NoPassphrase("A new passphrase is needed and there is no terminal to " "ask on.") given = getpass.getpass("New passphrase: ") @@ -224,7 +224,7 @@ async def _register(ctx: Context) -> int: "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: + if not terminal(sys.stdin) 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 @@ -355,7 +355,7 @@ async def _reset(ctx: Context) -> int: 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(): + if not terminal(sys.stdin): err(f"Then: meshbay-cli account reset -u {safe(username)} --code CODE") return 0 code = _ask("The code: ", None, "code") @@ -365,7 +365,7 @@ async def _reset(ctx: Context) -> int: 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(): + elif terminal(sys.stdin): recovery = input("Your recovery key (Enter if you have none): ").strip() rkey = None if recovery: @@ -429,7 +429,7 @@ async def _email(ctx: Context, account: Account) -> int: 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(): + if ctx.args.json or not terminal(sys.stdin): _say(ctx, {"email_verification_required": True, "email": address}, f"A code was sent to {safe(address)}. Then: meshbay-cli account email " "--code CODE") diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py index e410a6a..557592e 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -28,7 +28,7 @@ from ..client.groups import my_groups, open_group from ..client.paths import group_arg, handle, resolve_group from ..client.roster import RosterCheck from ..errors import NODE, USAGE, CliError -from ..output import date, err, human_size, safe, sentence +from ..output import date, err, human_size, safe, sentence, terminal from .context import Context HELP = ("chat: alone, interactively; with a group, its last 10 messages (-N for N), " @@ -90,7 +90,7 @@ async def run(ctx: Context) -> int: if a.group is None: if a.message is not None or a.follow: raise CliError(USAGE, "Name the group to post to.") - if not (sys.stdin.isatty() and sys.stdout.isatty()): + if not (terminal(sys.stdin) and terminal(sys.stdout)): raise CliError(USAGE, "The interactive chat needs a terminal; name a group " "to read it, or to post with -m.") from ..tui.app import run_interactive diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py index 831c012..0fc3f30 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/completion.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/completion.py @@ -51,7 +51,15 @@ Register-ArgumentCompleter -Native -CommandName 'meshbay-cli', 'meshbay-cli.exe' $index = $i } } - $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + # The client writes UTF-8; Windows PowerShell reads a program's output in the + # console's code page, which turns an accented name into another name. + $encoding = [Console]::OutputEncoding + try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch { } + try { + $found = @(& meshbay-cli __complete powershell $index @words 2>$null) + } finally { + try { [Console]::OutputEncoding = $encoding } catch { } + } $quote = { param($s) if ($s -match '[\s''"`$&|;(){}@,#]') { "'" + ($s -replace "'", "''") + "'" } else { $s } diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/context.py b/packages/meshbay-cli/src/meshbay_cli/commands/context.py index 827c101..0f7e842 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/context.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/context.py @@ -12,7 +12,7 @@ from ..client.hub import DEFAULT_HUB, Hub from ..client.paths import handle from ..client.store import Store from ..errors import USAGE, CliError -from ..output import safe +from ..output import safe, terminal class Context: @@ -45,7 +45,7 @@ class Context: if value is None: value = os.environ.get("MESHBAY_PASSWORD") if value is None: - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise NoPassphrase("A passphrase is needed and there is no terminal to ask " "on; pass --password-file or set MESHBAY_PASSWORD.") value = getpass.getpass(f"Passphrase for {self.username or self.stored_username}" @@ -62,7 +62,7 @@ class Context: def _device_code(self, group: dict, pending, *, first: bool) -> bool: """Show the code once. Wait only where somebody is there to approve it.""" - waiting = sys.stderr.isatty() and not self.args.quiet + waiting = terminal(sys.stderr) and not self.args.quiet if first: minutes = max(1, int((pending.expires_at - time.time()) // 60)) print(f"This machine is not yet a device of yours on the node hosting " diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/get.py b/packages/meshbay-cli/src/meshbay_cli/commands/get.py index eec73c4..33a9637 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/get.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/get.py @@ -12,7 +12,7 @@ import os from dataclasses import dataclass from pathlib import Path -from meshbay_common.paths import fold +from meshbay_common.paths import fold, long_path from ..client.groups import my_groups from ..client.names import UnsafeName, local_path @@ -147,7 +147,8 @@ async def _run(ctx: Context, account, pool: Pool) -> int: planned.add(key) if changed: renamed.append((shown, target)) - if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists(): + if os.path.exists(long_path(target)) and not os.path.exists( + long_path(target.with_name(target.name + PART_SUFFIX))): if a.skip: return if not a.force: @@ -181,7 +182,7 @@ async def _run(ctx: Context, account, pool: Pool) -> int: for problem in problems: progress.note(f"meshbay-cli: {safe(problem)}") for d in dirs: - d.mkdir(parents=True, exist_ok=True) + os.makedirs(long_path(d), exist_ok=True) progress.plan(len(jobs), sum(int(j.entry["size"]) for j in jobs)) failed = len(problems) @@ -224,12 +225,12 @@ def _destination(dest: str | None, single_file: bool) -> tuple[Path, str | None] return Path.cwd(), None path = Path(dest).expanduser() if dest.endswith(("/", os.sep)): - path.mkdir(parents=True, exist_ok=True) + os.makedirs(long_path(path), exist_ok=True) return path, None - if path.is_dir(): + if os.path.isdir(long_path(path)): return path, None if single_file: - if not path.parent.is_dir(): + if not os.path.isdir(long_path(path.parent)): raise CliError(USAGE, f"{path.parent} is not a folder.") return path.parent, path.name raise CliError(USAGE, f"{dest} is not a folder (end it with / to create it).") diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py index 04395dd..5ee1356 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/groups.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/groups.py @@ -10,7 +10,7 @@ operator is another notion. import contextlib import json import sys -from datetime import datetime +from datetime import UTC, datetime from ..client.groups import my_groups from ..client.hub import HubError @@ -38,9 +38,12 @@ def local_time(iso: str | None) -> str: if not iso: return "" try: - return datetime.fromisoformat(iso).astimezone().strftime("%Y-%m-%d %H:%M") + moment = datetime.fromisoformat(iso) except ValueError: return "" + if moment.tzinfo is None: # a hub on SQLite sends UTC without saying so + moment = moment.replace(tzinfo=UTC) + return moment.astimezone().strftime("%Y-%m-%d %H:%M") async def run(ctx: Context) -> int: diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/join.py b/packages/meshbay-cli/src/meshbay_cli/commands/join.py index ee75c96..06494dd 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/join.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/join.py @@ -18,7 +18,7 @@ from ..client.invite import parse_link from ..client.paths import group_arg, handle, resolve_group from ..client.session import DeviceLinkPending, NodeRefusal from ..errors import AUTH, NODE, NOT_FOUND, USAGE, CliError -from ..output import err, safe, sentence +from ..output import err, safe, sentence, terminal from .context import Context from .invitations import find_invitation @@ -102,7 +102,7 @@ async def run(ctx: Context) -> int: except NodeRefusal as e: if e.refusal_code != "code_required" or code: raise - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(NODE, f"You are a member of {safe(name)}; its node has not " "admitted you yet and needs your invitation's code:\n" f" meshbay-cli join {name} --code CODE") from e diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/login.py b/packages/meshbay-cli/src/meshbay_cli/commands/login.py index cbd16ef..f26691c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/login.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/login.py @@ -8,7 +8,7 @@ from ..client.account import Account from ..client.groups import my_groups from ..client.store import FILE_KEY_NOTICE, KEY_FILE from ..errors import USAGE, CliError -from ..output import err, safe, sentence +from ..output import err, safe, sentence, terminal from .context import Context from .logout import sign_out_stored @@ -24,7 +24,7 @@ def add_parser(sub, parent) -> None: async def run(ctx: Context) -> int: username = ctx.username or ctx.stored_username if not username: - if not sys.stdin.isatty(): + if not terminal(sys.stdin): raise CliError(USAGE, "Name the account with --user.") username = input("Username: ").strip() ctx.username = username diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/put.py b/packages/meshbay-cli/src/meshbay_cli/commands/put.py index 01a7233..cf80e02 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/put.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/put.py @@ -18,6 +18,8 @@ import tempfile import threading from pathlib import Path +from meshbay_common.paths import long_path + from ..client.groups import my_groups from ..client.paths import handle, parse, resolve_group from ..client.pool import Pool @@ -156,7 +158,7 @@ async def _send(ctx: Context, session, group: dict, folder: str, dirs: list[list progress.start(safe(name)) lock = threading.Lock() try: - with open(job.local, "rb") as fh: + with open(long_path(job.local), "rb") as fh: def read(offset: int, length: int) -> bytes: with lock: fh.seek(offset) |