aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 17:42:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 17:45:41 +0200
commit41d03f0a35a95daa7d54e7f29ab924776bedf6e4 (patch)
tree2e631013746f913413b9811562b9c9a4734a0e41 /packages/meshbay-cli
parent39cac2878de059992cb09a1ccf09b6bcece99fae (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py24
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/store.py5
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/transfers.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py33
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/account.py14
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/chat.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/completion.py10
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/context.py6
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/get.py13
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/groups.py7
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/join.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/login.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/put.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/complete.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/output.py34
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py13
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py28
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/panes.py3
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/screens.py10
-rw-r--r--packages/meshbay-cli/tests/console.py48
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py63
-rw-r--r--packages/meshbay-cli/tests/test_clipboard.py23
-rw-r--r--packages/meshbay-cli/tests/test_ctrl_c.py34
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py118
-rw-r--r--packages/meshbay-cli/tests/test_listing.py18
25 files changed, 427 insertions, 106 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py
index f5cee22..597e2fe 100644
--- a/packages/meshbay-cli/src/meshbay_cli/__main__.py
+++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py
@@ -8,7 +8,9 @@ import argparse
import asyncio
import logging
import os
+import signal
import sys
+import threading
from . import __version__
from .commands import (
@@ -113,15 +115,35 @@ async def _run(args: argparse.Namespace) -> int:
await ctx.aclose()
+class _WindowsSelectorLoop(asyncio.SelectorEventLoop):
+ """
+ A selector loop that Ctrl+C wakes. Windows' select() is not interrupted by
+ a console event, and unlike the proactor (`windows_events.ProactorEventLoop.
+ run_forever`) this loop never points the signal wakeup at its self-pipe: Ctrl+C
+ waited for the next timer, which in `chat --follow` or `stream` can be half
+ a minute away (measured: 29 s on a loop whose next timer was 30 s off).
+ """
+
+ def run_forever(self):
+ if threading.current_thread() is not threading.main_thread():
+ return super().run_forever() # signals reach the main thread only
+ old = signal.set_wakeup_fd(self._csock.fileno())
+ try:
+ super().run_forever()
+ finally:
+ signal.set_wakeup_fd(old)
+
+
def loop_factory():
"""aiortc's ICE needs a selector loop: Windows' default proactor never
completes a DataChannel handshake."""
- return asyncio.SelectorEventLoop if sys.platform == "win32" else None
+ return _WindowsSelectorLoop if sys.platform == "win32" else None
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if argv[:1] == ["__complete"]:
+ _utf8_output() # what the completion scripts read
from .complete import main as complete
return complete(argv[1:])
if not argv and os.environ.get("MESHBAY_CLI_SELFTEST") == "1":
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/store.py b/packages/meshbay-cli/src/meshbay_cli/client/store.py
index c48fe89..00ff9f3 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/store.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/store.py
@@ -63,7 +63,10 @@ def data_dir() -> Path:
def _write_private(path: Path, data: bytes) -> None:
"""Atomically, readable by this user only."""
tmp = path.with_name(path.name + ".tmp")
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ # O_BINARY: without it Windows opens in text mode and writes every 0x0A of
+ # the ciphertext as CR LF, and the secrets no longer open.
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
+ fd = os.open(tmp, flags, 0o600)
try:
os.write(fd, data)
os.fsync(fd)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/transfers.py b/packages/meshbay-cli/src/meshbay_cli/client/transfers.py
index 2cbf4cd..177c78b 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/transfers.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/transfers.py
@@ -128,7 +128,8 @@ class Resume:
sidecar = target.with_name(target.name + PART_SUFFIX + ".json")
done = 0
try:
- meta = json.loads(sidecar.read_text(encoding="utf-8"))
+ with open(long_path(sidecar), encoding="utf-8") as f:
+ meta = json.load(f)
if meta.get("id") == entry["id"] and meta.get("size") == entry["size"]:
# Every chunk but the last is whole, so the complete ones are
# what the part's length says.
@@ -198,8 +199,8 @@ def _prepare_part(resume: Resume, entry: dict, start: int) -> None:
pass
else:
os.truncate(part, start * DOWNLOAD_CHUNK)
- resume.sidecar.write_text(json.dumps({"id": entry["id"], "size": entry["size"]}),
- encoding="utf-8")
+ with open(long_path(resume.sidecar), "w", encoding="utf-8") as f:
+ json.dump({"id": entry["id"], "size": entry["size"]}, f)
def _publish(resume: Resume, target: Path, overwrite: bool) -> None:
@@ -220,7 +221,7 @@ def _publish(resume: Resume, target: Path, overwrite: bool) -> None:
raise TransferError(f"{target} already exists.") from None
os.replace(part, dest)
with contextlib.suppress(OSError):
- resume.sidecar.unlink()
+ os.unlink(long_path(resume.sidecar))
if sys.platform == "win32":
mark_of_the_web(target)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py
index 070e686..b4ec781 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/upload_plan.py
@@ -12,7 +12,7 @@ import stat
from dataclasses import dataclass
from pathlib import Path
-from meshbay_common.paths import fold
+from meshbay_common.paths import fold, long_path
from ..errors import USAGE, CliError
from ..output import safe
@@ -36,7 +36,7 @@ def plan_upload(sources: list[str], recursive: bool):
for src in sources:
path = Path(src).expanduser()
try:
- st = path.stat()
+ st = os.stat(long_path(path))
except OSError as e:
problems.append(f"{src}: {e.strerror}")
continue
@@ -51,18 +51,18 @@ def plan_upload(sources: list[str], recursive: bool):
elif stat.S_ISDIR(st.st_mode):
if not recursive:
raise CliError(USAGE, f"{safe(src)} is a folder; use -R.")
- for here, subdirs, names in os.walk(path):
+ for here, subdirs, names in _walk(path):
rel = [top] + list(Path(here).relative_to(path).parts)
dirs.append(rel)
for d in list(subdirs):
- if (Path(here) / d).is_symlink():
+ if os.path.islink(long_path(Path(here) / d)):
subdirs.remove(d)
problems.append(f"{Path(here) / d} is a link to a folder; not "
"followed")
for n in names:
p = Path(here) / n
try:
- st_n = p.stat()
+ st_n = os.stat(long_path(p))
except OSError as e:
problems.append(f"{p}: {e.strerror}")
continue
@@ -74,6 +74,29 @@ def plan_upload(sources: list[str], recursive: bool):
return dirs, files, problems
+def _walk(top: Path):
+ """`os.walk` for a tree that may go past Windows' MAX_PATH: each folder is
+ listed under its long form (`long_path`, which only prefixes a long path, so
+ a short top alone would not do) and the paths given back stay plain."""
+ stack = [top]
+ while stack:
+ here = stack.pop()
+ try:
+ with os.scandir(long_path(here)) as it:
+ entries = list(it)
+ except OSError:
+ continue
+ subdirs, names = [], []
+ for e in entries:
+ try:
+ is_dir = e.is_dir()
+ except OSError:
+ is_dir = False
+ (subdirs if is_dir else names).append(e.name)
+ yield here, subdirs, names # the caller prunes and orders subdirs
+ stack.extend(here / d for d in reversed(subdirs))
+
+
def taken_names(tree: Tree, folder: str, names: list[str]) -> set[str]:
"""The names among `names` already in `folder`, compared without case."""
there = [i.name for i in tree.children(folder, include_unavailable=True)]
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)
diff --git a/packages/meshbay-cli/src/meshbay_cli/complete.py b/packages/meshbay-cli/src/meshbay_cli/complete.py
index ac590ce..249d5b7 100644
--- a/packages/meshbay-cli/src/meshbay_cli/complete.py
+++ b/packages/meshbay-cli/src/meshbay_cli/complete.py
@@ -300,7 +300,9 @@ def candidates(shell: str, cword: int, words: list[str]) -> list[str]:
return [c[:-1] for c in found if c.count("/") == 1]
return found
try:
- found = asyncio.run(asyncio.wait_for(go(), DEADLINE))
+ # The client's loop: on Windows the default one never opens a node.
+ from .__main__ import loop_factory
+ found = asyncio.run(asyncio.wait_for(go(), DEADLINE), loop_factory=loop_factory())
except Exception:
found = []
return ([FILES] if kind[0] == "files+remote" else []) + found
diff --git a/packages/meshbay-cli/src/meshbay_cli/output.py b/packages/meshbay-cli/src/meshbay_cli/output.py
index d4c0758..1d46981 100644
--- a/packages/meshbay-cli/src/meshbay_cli/output.py
+++ b/packages/meshbay-cli/src/meshbay_cli/output.py
@@ -54,8 +54,36 @@ def columns(names: list[str]) -> str:
return "\n".join(lines)
+def terminal(stream) -> bool:
+ """
+ Whether `stream` is a terminal a person sits at. `isatty()` alone is wrong on
+ Windows, where it means "a character device" and so says yes for NUL: a
+ script run with `< NUL`, or by the Task Scheduler, was asked questions it
+ could not answer instead of being told to pass --yes.
+ """
+ try:
+ if not stream.isatty():
+ return False
+ except (AttributeError, ValueError):
+ return False
+ if sys.platform != "win32":
+ return True
+ try:
+ fd = stream.fileno()
+ except (OSError, ValueError):
+ return True # not a file at all: take its word
+ import ctypes
+ import msvcrt
+ try:
+ handle = msvcrt.get_osfhandle(fd)
+ except OSError:
+ return False
+ mode = ctypes.c_uint32()
+ return bool(ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode)))
+
+
def is_tty() -> bool:
- return sys.stdout.isatty()
+ return terminal(sys.stdout)
def err(message: str) -> None:
@@ -71,7 +99,7 @@ class Progress:
def __init__(self, arrow: str, *, quiet: bool = False, as_json: bool = False):
self.arrow = arrow
- self.live = sys.stderr.isatty() and not quiet and not as_json
+ self.live = terminal(sys.stderr) and not quiet and not as_json
self.as_json = as_json and not quiet
self.files_total = 0
self.files_done = 0
@@ -149,7 +177,7 @@ def confirm(question: str, yes: bool) -> None:
from .errors import USAGE, CliError
if yes:
return
- if not sys.stdin.isatty():
+ if not terminal(sys.stdin):
raise CliError(USAGE, f"{question} Not done: there is no terminal to ask on; "
"pass --yes to confirm.")
# On stderr, like every other word to the person: stdout is the result.
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
index e4ea1fe..989ffe2 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
@@ -21,6 +21,7 @@ import os
import time
from pathlib import Path
+from meshbay_common.paths import long_path
from rich.segment import Segment
from rich.text import Text
from textual import events
@@ -412,9 +413,10 @@ class ChatRoomScreen(Screen):
async def _put(self, arg: str) -> None:
path = Path(os.path.abspath(os.path.expanduser(arg)))
- if not path.is_file():
+ if not os.path.isfile(long_path(path)):
self.note(f"! {safe(arg)}: " + ("a folder; /put sends one file."
- if path.is_dir() else "no such file."), style="bold red")
+ if os.path.isdir(long_path(path)) else "no such file."),
+ style="bold red")
return
name = path.name
if problems := refused_names([name], []):
@@ -430,9 +432,9 @@ class ChatRoomScreen(Screen):
if found is None:
return
_, folder = found
- size = path.stat().st_size
+ size = os.stat(long_path(path)).st_size
advance, queued = self._progress("↑", name, size)
- with open(path, "rb") as fh:
+ with open(long_path(path), "rb") as fh:
def read(offset: int, length: int) -> bytes:
fh.seek(offset)
return fh.read(length)
@@ -475,7 +477,8 @@ class ChatRoomScreen(Screen):
style="bold red")
return
target = Path.cwd() / name.rsplit("/", 1)[-1]
- 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))):
self.note(f"! {safe(str(target))} is here already; nothing was downloaded.",
style="bold red")
return
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
index 1a1f0d7..e0bd315 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py
@@ -12,6 +12,7 @@ skipped, never an error.
import asyncio
import os
import shutil
+import subprocess
import sys
TIMEOUT = 3.0
@@ -40,24 +41,25 @@ def _encode(text: str) -> bytes:
return ("" + text).encode("utf-16-le") if sys.platform == "win32" else text.encode()
-async def _run(cmd: list[str], data: bytes | None) -> bytes | None:
+def _run_blocking(cmd: list[str], data: bytes | None) -> bytes | None:
if shutil.which(cmd[0]) is None:
return None
env = {**os.environ, "LC_CTYPE": "UTF-8"} if sys.platform == "darwin" else None
try:
- proc = await asyncio.create_subprocess_exec(
- *cmd, stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL,
- stdout=asyncio.subprocess.PIPE if data is None else asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL, env=env)
- except OSError:
- return None
- try:
- out, _ = await asyncio.wait_for(proc.communicate(data), TIMEOUT)
- except TimeoutError:
- proc.kill()
- await proc.wait()
+ proc = subprocess.run(
+ cmd, input=data, stdin=None if data is not None else subprocess.DEVNULL,
+ stdout=subprocess.PIPE if data is None else subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, env=env, timeout=TIMEOUT,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
+ except (OSError, subprocess.TimeoutExpired):
return None
- return (out or b"") if proc.returncode == 0 else None
+ return (proc.stdout or b"") if proc.returncode == 0 else None
+
+
+async def _run(cmd: list[str], data: bytes | None) -> bytes | None:
+ # In a thread, not asyncio's subprocesses: on Windows the client runs a
+ # selector loop (aiortc's ICE needs one), which cannot start a process.
+ return await asyncio.to_thread(_run_blocking, cmd, data)
async def read_text() -> str | None:
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/panes.py b/packages/meshbay-cli/src/meshbay_cli/tui/panes.py
index d1e3dd0..417e0c6 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/panes.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/panes.py
@@ -13,6 +13,7 @@ import stat
from dataclasses import dataclass, field
from pathlib import Path
+from meshbay_common.paths import long_path
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
@@ -274,7 +275,7 @@ class LocalPane(Pane):
def _scan(path: Path) -> list[Row]:
rows = []
- with os.scandir(path) as it:
+ with os.scandir(long_path(path)) as it:
for e in it:
try:
st = e.stat()
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
index 0306325..39b5df3 100644
--- a/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
+++ b/packages/meshbay-cli/src/meshbay_cli/tui/screens.py
@@ -14,9 +14,11 @@ the node's operator) or none is, then asked once for the batch.
import asyncio
import contextlib
+import os
import threading
from pathlib import Path
+from meshbay_common.paths import long_path
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
@@ -214,8 +216,8 @@ class MainScreen(Screen):
problems.append(str(e))
for p in problems:
self.app.notify(p, severity="warning", markup=False)
- existing = [t for _, t in plan if t.exists()
- and not t.with_name(t.name + PART_SUFFIX).exists()]
+ existing = [t for _, t in plan if os.path.exists(long_path(t))
+ and not os.path.exists(long_path(t.with_name(t.name + PART_SUFFIX)))]
overwrite = False
if existing:
names = ", ".join(safe(t.name) for t in existing[:5]) + (
@@ -229,7 +231,7 @@ class MainScreen(Screen):
if not overwrite:
plan = [(e, t) for e, t in plan if t not in existing]
for d in dirs:
- await asyncio.to_thread(d.mkdir, parents=True, exist_ok=True)
+ await asyncio.to_thread(os.makedirs, long_path(d), exist_ok=True)
remote.marks.clear()
remote.redraw()
session = await self.pool.session(group)
@@ -320,7 +322,7 @@ class MainScreen(Screen):
self.strip.update_job(job, state="running")
lock = threading.Lock()
try:
- with open(item.local, "rb") as fh:
+ with open(long_path(item.local), "rb") as fh:
def read(offset: int, length: int) -> bytes:
with lock:
fh.seek(offset)
diff --git a/packages/meshbay-cli/tests/console.py b/packages/meshbay-cli/tests/console.py
new file mode 100644
index 0000000..d3ea6c9
--- /dev/null
+++ b/packages/meshbay-cli/tests/console.py
@@ -0,0 +1,48 @@
+"""
+A person's Ctrl+C, delivered to a client run started by a test.
+
+On Windows Ctrl+C is a console event, not a signal, so the run gets a hidden
+console of its own and a helper attached to that console raises the event. The
+run goes through a launcher that turns Ctrl+C handling back on first: a process
+inherits "ignore Ctrl+C" from its parent, and whatever started the test suite
+may have set it (an IDE, a CI runner, an agent's shell) where a person's console
+never does — measured, the event then reached nothing at all.
+"""
+
+import subprocess
+import sys
+
+_LAUNCHER = ("import ctypes, signal, subprocess, sys\n"
+ "ctypes.windll.kernel32.SetConsoleCtrlHandler(None, 0)\n"
+ "signal.signal(signal.SIGINT, signal.SIG_IGN)\n"
+ "sys.exit(subprocess.call(sys.argv[1:]))\n")
+
+# os._exit: with the console swapped, flushing stdout at exit fails (code 120).
+_SENDER = ("import ctypes, os, sys\n"
+ "k = ctypes.windll.kernel32\n"
+ "k.FreeConsole()\n"
+ "ok = k.AttachConsole(int(sys.argv[1])) and k.SetConsoleCtrlHandler(None, 1)\n"
+ "os._exit(0 if ok and k.GenerateConsoleCtrlEvent(0, 0) else 1)\n")
+
+
+def interruptible(argv: list[str], env: dict | None) -> subprocess.Popen:
+ """`argv` started so that `ctrl_c` reaches it, its output read as UTF-8."""
+ extra: dict = {}
+ if sys.platform == "win32":
+ si = subprocess.STARTUPINFO()
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ si.wShowWindow = 0 # SW_HIDE
+ extra = {"creationflags": subprocess.CREATE_NEW_CONSOLE, "startupinfo": si}
+ argv = [sys.executable, "-c", _LAUNCHER, *argv]
+ return subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, encoding="utf-8", **extra)
+
+
+def ctrl_c(proc: subprocess.Popen) -> None:
+ if sys.platform != "win32":
+ import signal
+ proc.send_signal(signal.SIGINT)
+ return
+ subprocess.run([sys.executable, "-c", _SENDER, str(proc.pid)], check=True, timeout=30,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ creationflags=subprocess.CREATE_NO_WINDOW)
diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py
index 006c183..ad914fd 100644
--- a/packages/meshbay-cli/tests/test_cli_actions.py
+++ b/packages/meshbay-cli/tests/test_cli_actions.py
@@ -83,6 +83,17 @@ def test_no_passphrase_and_no_terminal_exits_2(hub, monkeypatch, capsys):
assert "MESHBAY_PASSWORD" in capsys.readouterr().err
+def test_the_null_device_is_no_terminal(hub, monkeypatch, capsys):
+ """`< NUL` on Windows: a character device, which isatty() calls a terminal.
+ A script run so was asked for a passphrase it could not type."""
+ from meshbay_cli.output import terminal
+ with open(os.devnull) as null:
+ assert not terminal(null)
+ monkeypatch.setattr("sys.stdin", null)
+ assert main("--hub", hub.url, "-u", USERNAME, "login") == 2
+ assert "MESHBAY_PASSWORD" in capsys.readouterr().err
+
+
async def _session():
return FakeSession()
@@ -177,6 +188,58 @@ def test_put_into_a_read_only_folder_sends_nothing(node, tmp_path, capsys):
assert "read-only" in capsys.readouterr().err
+def _max_path_in_force() -> bool:
+ if sys.platform != "win32":
+ return False
+ import winreg
+ try:
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
+ r"SYSTEM\CurrentControlSet\Control\FileSystem") as key:
+ return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 0
+ except OSError:
+ return True
+
+
+@pytest.mark.skipif(not _max_path_in_force(), reason="Windows with MAX_PATH in force")
+def test_get_and_put_past_max_path(node, tmp_path, capsys):
+ """Windows refuses a plain path of 260 characters or more. The node uses the
+ long form at each disk call, and the client has to as well: `get -R` into a
+ deep folder died with a traceback creating its first folder."""
+ import shutil
+
+ from meshbay_common.paths import long_path
+ segment = "A folder name as long as the ones a real library has"
+ deep = tmp_path / "deep"
+ while len(str(deep)) < 240:
+ deep = deep / segment
+ try:
+ node.add("share/2024", "a.jpg", b"A" * 3000)
+ assert main("get", "-R", "Family/share/2024", "-o", f"{deep}{os.sep}") == 0, \
+ capsys.readouterr().err
+ got = deep / "2024" / "a.jpg"
+ assert len(str(got)) >= 260
+ with open(long_path(got), "rb") as f:
+ assert f.read() == b"A" * 3000
+
+ # A part left from an interrupted run, resumed past the limit too.
+ node.add("share/2024", "b.bin", b"B" * 10)
+ with open(long_path(deep / "2024" / "b.bin.meshbay-part"), "wb") as f:
+ f.write(b"B")
+ assert main("get", "-R", "Family/share/2024", "-o", f"{deep}{os.sep}", "-n") == 0
+ with open(long_path(deep / "2024" / "b.bin"), "rb") as f:
+ assert f.read() == b"B" * 10
+
+ # And a tree past the limit, sent.
+ src = deep / "Scans"
+ os.makedirs(long_path(src / segment))
+ with open(long_path(src / segment / "page.pdf"), "wb") as f:
+ f.write(b"%PDF")
+ assert main("put", "-R", str(src), "Family/share") == 0, capsys.readouterr().err
+ assert node.stored(f"share/Scans/{segment}/page.pdf") == b"%PDF"
+ finally:
+ shutil.rmtree("\\\\?\\" + str(tmp_path / "deep"), ignore_errors=True)
+
+
def test_trust_lists_and_forgets_pins(hub, capsys):
assert main("trust", "list") == 0 # nothing stored: nothing pinned
assert main("--hub", hub.url, "-u", USERNAME, "--password", PASSPHRASE,
diff --git a/packages/meshbay-cli/tests/test_clipboard.py b/packages/meshbay-cli/tests/test_clipboard.py
index 6296317..20e90b6 100644
--- a/packages/meshbay-cli/tests/test_clipboard.py
+++ b/packages/meshbay-cli/tests/test_clipboard.py
@@ -10,7 +10,7 @@ import time
import pytest
from meshbay_cli.tui import clipboard
-pytestmark = pytest.mark.skipif(sys.platform in ("win32", "darwin"),
+unix_tools = pytest.mark.skipif(sys.platform in ("win32", "darwin"),
reason="the Wayland and X11 tools")
# Found before a test leaves only the fake tools on PATH.
CAT, SLEEP = shutil.which("cat"), shutil.which("sleep")
@@ -31,12 +31,14 @@ def wayland(tmp_path, monkeypatch):
return held
+@unix_tools
async def test_what_is_copied_is_what_is_pasted(wayland):
assert await clipboard.write_text("héllo — 你好\nsecond") is True
assert wayland.read_text(encoding="utf-8") == "héllo — 你好\nsecond"
assert await clipboard.read_text() == "héllo — 你好\nsecond"
+@unix_tools
async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch):
monkeypatch.setenv("PATH", str(tmp_path))
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
@@ -45,14 +47,33 @@ async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch):
assert await clipboard.write_text("x") is False
+@unix_tools
async def test_a_failing_tool_is_skipped(wayland):
(wayland.parent / "bin" / "wl-paste").write_text("#!/bin/sh\nexit 1\n")
assert await clipboard.read_text() is None
+@unix_tools
async def test_a_hanging_tool_is_given_up(wayland, monkeypatch):
(wayland.parent / "bin" / "wl-paste").write_text(f"#!/bin/sh\nexec {SLEEP} 30\n")
monkeypatch.setattr(clipboard, "TIMEOUT", 0.3)
started = time.monotonic()
assert await clipboard.read_text() is None
assert 0.3 <= time.monotonic() - started < 5
+
+
+def test_the_tools_run_on_a_selector_loop(monkeypatch):
+ """The client's loop on Windows (`__main__.loop_factory`), which cannot start
+ a process through asyncio: right-click copy and paste raised there."""
+ import asyncio
+
+ def tools(read):
+ return [[sys.executable, "-c", "print('pasted', end='')" if read
+ else "import sys; sys.stdin.buffer.read()"]]
+ monkeypatch.setattr(clipboard, "_tools", tools)
+ loop = asyncio.SelectorEventLoop()
+ try:
+ assert loop.run_until_complete(clipboard.read_text()) == "pasted"
+ assert loop.run_until_complete(clipboard.write_text("x")) is True
+ finally:
+ loop.close()
diff --git a/packages/meshbay-cli/tests/test_ctrl_c.py b/packages/meshbay-cli/tests/test_ctrl_c.py
new file mode 100644
index 0000000..53a798a
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_ctrl_c.py
@@ -0,0 +1,34 @@
+"""
+Ctrl+C stops the client at once, whatever its loop is waiting for.
+
+On Windows the client runs a selector loop (aiortc's ICE needs one), and a
+console event does not interrupt select(): Ctrl+C waited for the loop's next
+timer — half a minute in `chat --follow` between pings.
+"""
+
+import sys
+import time
+
+from console import ctrl_c, interruptible
+
+IDLE = ("import asyncio, sys\n"
+ "from meshbay_cli.__main__ import loop_factory\n"
+ "print('ready', flush=True)\n"
+ "try:\n"
+ " asyncio.run(asyncio.sleep(60), loop_factory=loop_factory())\n"
+ "except KeyboardInterrupt:\n"
+ " sys.exit(130)\n")
+
+
+def test_ctrl_c_wakes_an_idle_client_at_once():
+ proc = interruptible([sys.executable, "-c", IDLE], None)
+ try:
+ assert proc.stdout.readline().strip() == "ready", proc.stderr.read()
+ started = time.monotonic()
+ ctrl_c(proc)
+ proc.wait(timeout=30)
+ assert proc.returncode == 130, proc.stderr.read()
+ assert time.monotonic() - started < 5
+ finally:
+ if proc.poll() is None:
+ proc.kill()
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 6b7869b..b131658 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -42,6 +42,7 @@ from pathlib import Path
import httpx
import pytest
+from console import ctrl_c, interruptible
pytestmark = [
pytest.mark.skipif(os.environ.get("MESHBAY_CLI_E2E") != "1",
@@ -53,6 +54,28 @@ USERNAME = "alice-e2e-cli"
PASSPHRASE = "a long enough test passphrase, nobody's"
GROUP_NAME = "Some Saga/Box @Set"
BIN = Path(sys.executable).parent
+# The environment before conftest points HOME, USERPROFILE and LOCALAPPDATA at an
+# empty directory for each test: Chrome on Windows does not start in one.
+REAL_ENV = dict(os.environ)
+# Windows refuses a control character in a file name, so a node there cannot
+# hold the name with an escape character in it.
+ESCAPE_NAME = sys.platform != "win32"
+
+
+def _program(linux: str, *windows: str) -> str | None:
+ """A program by its Linux name, or where Windows installs it."""
+ found = shutil.which(linux)
+ if found or sys.platform != "win32":
+ return found
+ for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
+ candidate = os.path.join(base or "", *windows)
+ if base and os.path.isfile(candidate):
+ return candidate
+ return None
+
+
+CHROME = _program("google-chrome", "Google", "Chrome", "Application", "chrome.exe")
+VLC = _program("cvlc", "VideoLAN", "VLC", "vlc.exe")
def _free_port() -> int:
@@ -139,8 +162,13 @@ def _build(root: Path, procs: list) -> dict:
(share / "2024" / "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac")\
.write_bytes(secrets.token_bytes(2_500_000))
(share / "notes.txt").write_text("hello\n")
- (share / "evil\x1b[31mred.txt").write_text("escape\n")
- node_env = {**os.environ, "HOME": str(node_home),
+ if ESCAPE_NAME:
+ (share / "evil\x1b[31mred.txt").write_text("escape\n")
+ # USERPROFILE, LOCALAPPDATA and APPDATA: where the node's directories
+ # are on Windows; HOME alone would leave it in the developer's own.
+ node_env = {**os.environ, "HOME": str(node_home), "USERPROFILE": str(node_home),
+ "LOCALAPPDATA": str(node_home / "AppData" / "Local"),
+ "APPDATA": str(node_home / "AppData" / "Roaming"),
"XDG_CONFIG_HOME": str(node_home / ".config"),
"XDG_DATA_HOME": str(node_home / ".local/share"),
"XDG_STATE_HOME": str(node_home / ".local/state")}
@@ -398,7 +426,7 @@ def cli(world: dict, *args: str, stdin: str | None = None,
env.update(env_extra or {})
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
return subprocess.run([exe, *args], env=env, input=stdin,
- capture_output=True, text=True, timeout=180)
+ capture_output=True, text=True, encoding="utf-8", timeout=180)
def _handle() -> str:
@@ -438,7 +466,8 @@ def test_the_cli_against_a_real_hub_and_node(world):
out = r.stdout
assert "plage de l'ouest.jpg" in out
assert "A_Very_Long_Name_Without_Any_Spaces_In_It_1992-2000.flac" in out
- assert "\x1b" not in out and "evil\\x1b[31mred.txt" in out
+ assert "\x1b" not in out
+ assert not ESCAPE_NAME or "evil\\x1b[31mred.txt" in out
assert "été à la mer/" in out
# By name alone, case-insensitively, and as JSON.
@@ -467,8 +496,9 @@ def test_the_cli_against_a_real_hub_and_node(world):
assert (got / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes() == \
(world["share"] / "2024" / "été à la mer" / "plage de l'ouest.jpg").read_bytes()
# The escape character could not be written as it is: renamed, and said so.
- assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr
- assert (got / "evil_[31mred.txt").exists()
+ if ESCAPE_NAME:
+ assert "evil\\x1b[31mred.txt is saved as evil_[31mred.txt" in r.stderr
+ assert (got / "evil_[31mred.txt").exists()
r = cli(world, "get", f"{_handle()}/share/notes.txt", "-o", "-")
assert r.returncode == 0 and r.stdout == "hello\n"
@@ -486,7 +516,8 @@ def test_the_cli_against_a_real_hub_and_node(world):
# notes.txt is there already, whatever the case: nothing sent.
r = cli(world, "put", str(local / "Notes.TXT"), f"{_handle()}/share")
assert r.returncode == 8 and "already there" in r.stderr
- assert not (world["share"] / "Notes.TXT").exists()
+ # By listing, not exists(): on Windows that finds notes.txt under any case.
+ assert "Notes.TXT" not in os.listdir(world["share"])
r = cli(world, "put", "-", f"{_handle()}/share/piped.txt", stdin="through stdin")
assert r.returncode == 0, r.stderr
assert (world["share"] / "piped.txt").read_text() == "through stdin"
@@ -739,10 +770,10 @@ class _Chrome:
ignore_cleanup_errors=True)
port = _free_port()
self.proc = subprocess.Popen(
- ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ [CHROME, "--headless=new", "--disable-gpu", "--no-sandbox",
f"--remote-debugging-port={port}", f"--user-data-dir={self.profile.name}",
"--lang=en-US", "about:blank"],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=REAL_ENV)
deadline = time.monotonic() + 30
while True:
try:
@@ -830,8 +861,7 @@ def _linked_group_page(world: dict) -> "_Chrome":
raise
-@pytest.mark.skipif(not shutil.which("google-chrome"),
- reason="google-chrome is not available")
+@pytest.mark.skipif(not CHROME, reason="Chrome is not available")
def test_the_application_links_itself_from_its_group_page(world):
_linked_group_page(world).close()
@@ -997,8 +1027,7 @@ def _cli_signed_in(world: dict, home: str) -> None:
time.sleep(5)
-@pytest.mark.skipif(not shutil.which("google-chrome"),
- reason="google-chrome is not available")
+@pytest.mark.skipif(not CHROME, reason="Chrome is not available")
def test_chat_between_the_command_line_and_the_application(world):
"""Posted here, read on the group page in Chrome; answered there, read here —
the Python seal and the page's crypto.js, on a real node."""
@@ -1162,7 +1191,7 @@ async def test_put_and_get_in_the_interactive_chat_against_the_real_node(
assert json.loads(r.stdout)[0]["attachment"] == {
"filename": "from the room.bin", "size": len(sent), "type": "file"}
- if shutil.which("google-chrome"):
+ if CHROME:
await asyncio.to_thread(_the_page_shows_the_file, world, "from the room.bin")
@@ -1183,13 +1212,11 @@ def test_chat_follow_against_the_real_node(world):
"""`chat --follow` in its own process: messages posted from another run
arrive as the node pushes them, across more than one liveness ping, and
Ctrl+C ends it. Runs on the machine the chat tests above linked."""
- import signal
group = _handle()
env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"}
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
- follower = subprocess.Popen([exe, "--json", "chat", "-1", group, "--follow"], env=env,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ follower = interruptible([exe, "--json", "chat", "-1", group, "--follow"], env)
try:
first = json.loads(follower.stdout.readline()) # the last one, at once
assert first["text"] or first.get("attachment") # /put's, above
@@ -1201,20 +1228,19 @@ def test_chat_follow_against_the_real_node(world):
assert got["text"] == text and got["sender"] == USERNAME, got
assert got["trust"] in ("pinned", "first", "linked")
finally:
- follower.send_signal(signal.SIGINT)
+ ctrl_c(follower)
_, err = follower.communicate(timeout=20)
assert follower.returncode == 130
assert "reconnecting" not in err, err
@pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")
- and shutil.which("cvlc")),
+ and VLC),
reason="ffmpeg, ffprobe and VLC are needed")
def test_stream_a_film_to_real_players(world, tmp_path):
"""A film put on the real node, then streamed: ffprobe reads it, ffmpeg seeks
near its end, a foreign Host is refused, Ctrl+C stops it; then VLC started
with --player plays it to the end and, closing, ends the stream."""
- import signal
_cli_signed_in(world, "cli-stream")
film = tmp_path / "Night Drive.mkv"
subprocess.run(["ffmpeg", "-v", "error", "-f", "lavfi", "-i",
@@ -1241,8 +1267,7 @@ def test_stream_a_film_to_real_players(world, tmp_path):
env = {**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-stream"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring"}
exe = os.environ.get("MESHBAY_CLI_E2E_BIN") or str(BIN / "meshbay-cli")
- streamer = subprocess.Popen([exe, "--json", "stream", path], env=env,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ streamer = interruptible([exe, "--json", "stream", path], env)
try:
line = streamer.stdout.readline()
assert line, streamer.stderr.read()
@@ -1266,20 +1291,24 @@ def test_stream_a_film_to_real_players(world, tmp_path):
trust_env=False)
assert foreign.status_code == 403
finally:
- streamer.send_signal(signal.SIGINT)
+ ctrl_c(streamer)
_, err = streamer.communicate(timeout=20)
assert streamer.returncode == 130, err
with pytest.raises(httpx.HTTPError):
httpx.get(url, timeout=5, trust_env=False)
- player = tmp_path / "vlc-to-the-end"
log = tmp_path / "vlc.log"
- player.write_text("#!/bin/sh\nexec cvlc --play-and-exit --intf dummy --vout dummy "
- f"--aout dummy \"$1\" >{log} 2>&1\n")
- player.chmod(0o755)
+ flags = "--play-and-exit --intf dummy --vout dummy --aout dummy"
+ if sys.platform == "win32":
+ player = tmp_path / "vlc-to-the-end.cmd"
+ player.write_text(f'@"{VLC}" {flags} "%~1" >"{log}" 2>&1\r\n')
+ else:
+ player = tmp_path / "vlc-to-the-end"
+ player.write_text(f"#!/bin/sh\nexec {VLC} {flags} \"$1\" >{log} 2>&1\n")
+ player.chmod(0o755)
started = time.monotonic()
r = subprocess.run([exe, "stream", "--player", str(player), path], env=env,
- capture_output=True, text=True, timeout=120)
+ capture_output=True, text=True, encoding="utf-8", timeout=120)
assert r.returncode == 0, r.stderr + log.read_text()
assert "The player was closed" in r.stderr
assert time.monotonic() - started > 10, "VLC played the film, in real time"
@@ -1354,23 +1383,30 @@ def test_tab_completes_remote_paths_from_the_real_node(world):
"COMP_CWORD=2\n_meshbay_cli\nprintf '%s\\n' \"${COMPREPLY[@]}\"\n"],
env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
"PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
- "PATH": f"{BIN}:{os.environ['PATH']}"},
- capture_output=True, text=True, timeout=60)
+ "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"},
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
assert bash.stdout.splitlines() == [group + "/"], (bash.stdout, bash.stderr)
- pwsh = shutil.which("pwsh")
+ # PowerShell 7, or Windows PowerShell 5.1, which decodes a program's output
+ # with the console's code page unless told otherwise: an accented name is the
+ # test. The matches come back as code points, past PowerShell's own output.
+ pwsh = shutil.which("pwsh") or (sys.platform == "win32" and shutil.which("powershell"))
if pwsh:
ps = cli(world, "completion", "powershell").stdout
- line = f"meshbay-cli ls '{group}/sh"
- r = subprocess.run(
- [pwsh, "-NoProfile", "-Command",
- ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}"
- "\n$r.CompletionMatches | ForEach-Object { $_.CompletionText }"],
- env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
- "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
- "PATH": f"{BIN}:{os.environ['PATH']}"},
- capture_output=True, text=True, timeout=120)
- assert r.stdout.splitlines() == [f"'{group}/share/'"], (r.stdout, r.stderr)
+ for line, want in ((f"meshbay-cli ls '{group}/sh", f"'{group}/share/'"),
+ (f"meshbay-cli ls '{group}/share/2024/é",
+ f"'{group}/share/2024/été à la mer/'")):
+ r = subprocess.run(
+ [pwsh, "-NoProfile", "-Command",
+ ps + f"\n$r = TabExpansion2 -inputScript \"{line}\" -cursorColumn {len(line)}"
+ "\n$r.CompletionMatches | ForEach-Object {"
+ " ($_.CompletionText.ToCharArray() | ForEach-Object { [int]$_ }) -join ',' }"],
+ env={**os.environ, "MESHBAY_CLI_HOME": str(world["root"] / "cli-chat"),
+ "PYTHON_KEYRING_BACKEND": "keyring.backends.fail.Keyring",
+ "PATH": f"{BIN}{os.pathsep}{os.environ['PATH']}"},
+ capture_output=True, text=True, timeout=120)
+ got = ["".join(map(chr, map(int, m.split(",")))) for m in r.stdout.split()]
+ assert got == [want], (got, r.stderr)
def _cli(world: dict, *args: str, new: str | None = None, **kw):
diff --git a/packages/meshbay-cli/tests/test_listing.py b/packages/meshbay-cli/tests/test_listing.py
index 407074e..f3e6a85 100644
--- a/packages/meshbay-cli/tests/test_listing.py
+++ b/packages/meshbay-cli/tests/test_listing.py
@@ -71,3 +71,21 @@ def test_control_characters_and_bidi_overrides_are_escaped():
def test_sizes():
assert human_size(6) == "6 B"
assert human_size(2_500_000) == "2.4 MB"
+
+
+def test_an_activity_time_without_a_zone_is_utc(monkeypatch):
+ """A hub on SQLite sends `last_activity_at` without its zone; it is UTC, as
+ PostgreSQL's says it is, and is shown in local time either way."""
+ import time
+
+ from meshbay_cli.commands.groups import local_time
+ if not hasattr(time, "tzset"):
+ pytest.skip("needs time.tzset to set the local zone")
+ monkeypatch.setenv("TZ", "UTC-02:00")
+ time.tzset()
+ try:
+ assert local_time("2026-10-15T13:59:00") == "2026-10-15 15:59"
+ assert local_time("2026-10-15T13:59:00+00:00") == "2026-10-15 15:59"
+ finally:
+ monkeypatch.undo()
+ time.tzset()