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/client | |
| 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/client')
3 files changed, 37 insertions, 10 deletions
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)] |