feat(mosh): integrate MoshCatty pure Rust client (no Cygwin legacy) (#2085)

* feat(mosh): default bundled client to MoshCatty releases

Wire Netcatty packaging and npm run dev to pull pure Rust mosh-client
binaries from binaricat/MoshCatty (moshcatty-* tags), document the cutover,
and mark the old Cygwin Windows fetch path as legacy-only.

* fix(mosh): accept pure MoshCatty packages (exe-only, no dlls/terminfo)

Align fetch/packaging with MoshCatty pure-Rust releases: require only
mosh-client[.exe], treat Cygwin dll bags and terminfo as optional legacy
layout. Document moshcatty-0.1.1 (ConPTY Ctrl+C + static MSVC CRT).

* refactor(mosh): drop Cygwin legacy; MoshCatty-only pipeline

Remove FluentTerminal/mosh-bin fallbacks, build-mosh C++ CI, and
dll/terminfo runtime helpers. Fetch accepts only moshcatty-* pure
binaries; packaging ships a single mosh-client per platform.

* docs: align ET checklist and et README with MoshCatty-only mosh

Update mosh template references after removing Cygwin build pipeline and
legacy fallbacks. Point ET packaging notes at fetch-mosh / MoshCatty
instead of deleted build-mosh-binaries workflow.

* docs(mosh): require MoshCatty 0.1.2+ for Linux glibc floors

Codex review vs main: defaulting to MoshCatty Linux binaries built on
ubuntu-latest required GLIBC 2.34, above Netcatty's package floors
(x64 2.28 / arm64 2.31). Document the floors, prefer moshcatty-0.1.2+,
and drop trailing whitespace in the design note. Upstream MoshCatty
release CI now builds in almalinux:8 / debian:bullseye with assert.

* fix(mosh): reject MoshCatty releases below 0.1.2

Codex re-review: docs alone still allowed MOSH_BIN_RELEASE=moshcatty-0.1.0/0.1.1,
which produce packages that fail to start on Netcatty's Linux glibc floors.
Validate the minimum tag in resolve and fetch, skip pre-0.1.2 when picking
latest, and cover the reject path in unit tests.

* fix(mosh): pin MoshCatty owner and reject floor prereleases

Codex review vs main (P2):
- Always default MOSH_BIN owner/repo to binaricat/MoshCatty; do not
  inherit fork owner from GITHUB_REPOSITORY in resolve or fetch.
- Treat moshcatty-X.Y.Z-rcN as below final X.Y.Z so pins like
  moshcatty-0.1.2-rc1 cannot bypass the glibc floor.

* fix(mosh): accept build metadata in MoshCatty release tags

TAG_RE rejected '+' so moshcatty-0.1.2+build.1 failed validation even
though the version parser and isAtLeastMinRelease accepted it. Align
the regex and require a successful semver parse.
This commit is contained in:
陈大猫 authored and GitHub committed 2026-07-10 20:24:24 +08:00
1 parent 46c4be135f
commit 01e7edc006
19 files changed
+482 -1808

No files matched your search

-234
View File
@@ -1,234 +0,0 @@
name: build-mosh-binaries
# Trigger philosophy (mirrors build.yml):
# - Pushes that touch the mosh build pipeline + PRs run the matrix
# so we can validate workflow / script changes without tagging.
# Artifacts upload as workflow artifacts only; *no* release.
# - Manual `workflow_dispatch` with `release_tag` publishes the
# binaries + SHA256SUMS to the dedicated binary repository
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
mosh_ref:
description: "mosh upstream git ref (tag/branch/commit) — see https://github.com/mobile-shell/mosh"
type: string
default: "mosh-1.4.0"
release_tag:
description: "Optional release tag to attach binaries to (e.g. mosh-bin-1.4.0-1). Empty = artifacts only."
type: string
default: ""
release_repo:
description: "Repository that stores mosh-client binary releases."
type: string
default: "binaricat/Netcatty-mosh-bin"
push:
branches:
- "**"
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
pull_request:
paths:
- ".gitattributes"
- ".github/workflows/build-mosh-binaries.yml"
- "electron-builder.config.cjs"
- "package.json"
- "scripts/build-mosh/**"
- "scripts/fetch-mosh-binaries.cjs"
- "scripts/mosh-extra-resources.cjs"
# Cancel superseded branch / PR builds.
concurrency:
group: build-mosh-binaries-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MOSH_REF: ${{ inputs.mosh_ref || 'mosh-1.4.0' }}
jobs:
# ------------------------------------------------------------------
# Linux x64 (manylinux2014 / glibc 2.17, broad distro compatibility).
# Static-links the heavy third-party deps where possible; the resulting
# mosh-client still depends on baseline Linux system libraries.
# ------------------------------------------------------------------
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-x64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=x64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-x64
path: out/
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (linux-arm64)
run: |
# Run only the compiler inside manylinux2014. JavaScript actions
# need the host runner's newer glibc.
docker run --rm \
-e MOSH_REF="${MOSH_REF}" \
-e OUT_DIR=/work/out \
-e ARCH=arm64 \
-v "${GITHUB_WORKSPACE}:/work" \
-w /work \
quay.io/pypa/manylinux2014_aarch64 \
bash scripts/build-mosh/build-linux.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-linux-arm64
path: out/
# ------------------------------------------------------------------
# macOS universal2 (arm64 + x86_64 lipo).
# Min deployment target: macOS 11 (Big Sur) — covers arm64 hardware.
# Static-links OpenSSL, protobuf, ncurses for both arches.
# ------------------------------------------------------------------
build-macos-universal:
name: build-macos-universal
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
- name: Build mosh-client (darwin-universal)
env:
MOSH_REF: ${{ env.MOSH_REF }}
OUT_DIR: ${{ github.workspace }}/out
MACOSX_DEPLOYMENT_TARGET: "11.0"
run: bash scripts/build-mosh/build-macos.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-darwin-universal
path: out/
# ------------------------------------------------------------------
# Windows x64 pinned runtime bundle.
# Do not compile this in CI: rebuilding the upstream Cygwin client here
# previously regressed Windows startup. Ship the SHA256-pinned Netcatty
# bundle with DLLs + terminfo, plus the FluentTerminal standalone fallback,
# verified by fetch-windows.sh.
# ------------------------------------------------------------------
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch pinned mosh-client bundle (win32-x64)
run: |
set -euo pipefail
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: mosh-client-win32-x64
path: out/
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Aggregate + optional release to the dedicated binary repository.
# ------------------------------------------------------------------
release:
name: release
needs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage release files
run: |
set -euo pipefail
mkdir -p release
for d in artifacts/*/; do
find "$d" -maxdepth 1 -type f -exec cp {} release/ \;
done
(cd release && find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\n' | sort | xargs sha256sum > SHA256SUMS)
ls -la release
cat release/SHA256SUMS
- name: Determine tag
id: tag
env:
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
tag="${RELEASE_TAG}"
if [[ ! "$tag" =~ ^mosh-bin-[A-Za-z0-9._-]+$ ]]; then
echo "Invalid mosh binary release tag: $tag" >&2
exit 1
fi
printf 'name=%s\n' "$tag" >> "$GITHUB_OUTPUT"
- name: Create / update release
env:
GH_TOKEN: ${{ secrets.MOSH_BIN_RELEASE_TOKEN }}
RELEASE_REPO: ${{ inputs.release_repo }}
RELEASE_TAG: ${{ steps.tag.outputs.name }}
run: |
set -euo pipefail
if [[ -z "${GH_TOKEN:-}" ]]; then
echo "::error::MOSH_BIN_RELEASE_TOKEN is required to publish into ${RELEASE_REPO}."
exit 1
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned Netcatty runtime bundle, with the FluentTerminal standalone `mosh-client.exe` kept as a fallback asset.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
} > release-notes.md
if gh release view "${RELEASE_TAG}" --repo "${RELEASE_REPO}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
gh release upload "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--clobber
else
gh release create "${RELEASE_TAG}" release/* \
--repo "${RELEASE_REPO}" \
--title "${RELEASE_TAG}" \
--notes-file release-notes.md
fi
+3 -8
View File
@@ -77,16 +77,11 @@ Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
# and the bundled ncurses terminfo database are all pulled from the
# dedicated mosh binary repository, never committed.
# Bundled MoshCatty mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is committed;
# pure single-binary artifacts from binaricat/MoshCatty are never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/
# Bundled EternalTerminal `et` client binaries fetched at pack time by
# scripts/fetch-et-binaries.cjs. resources/et/README.md is committed; the
+28 -25
View File
@@ -8,37 +8,42 @@
> 后端 + UI 重新落到上游重构后的目录结构上,并让它启动**捆绑的** `et`。
>
> 旧实现参考:`git show 67e81616`(共 7 个 ET 提交,见 `feat/eternal-terminal`)。
> Mosh 模板参考:`resources/mosh/README.md`、`scripts/*mosh*`、
> `electron/bridges/terminalBridge/moshSession.cjs`、`.github/workflows/build-mosh-binaries.yml`。
> Mosh 模板参考(**仅 MoshCatty 纯二进制路径**):`resources/mosh/README.md`、
> `scripts/fetch-mosh-binaries.cjs`、`scripts/resolve-mosh-bin-release.cjs`、
> `scripts/mosh-extra-resources.cjs`、`electron/bridges/terminalBridge/moshSession.cjs`。
> 客户端本体在独立仓库 [binaricat/MoshCatty](https://github.com/binaricat/MoshCatty)
> (`moshcatty-*` releases);Netcatty 内已无 Cygwin 构建流水线 / FluentTerminal 回退。
## 关键设计差异(ET vs Mosh)
- **协议**:Mosh 需要 Node 重写 Perl 包装器(SSH bootstrap + 抓 `MOSH CONNECT` +
换 PTY)。**ET 不需要** —— `et` 客户端自己完成 SSH 引导 + 协议握手,我们只要
把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有** `etHandshake.cjs`。
- **协议**:Mosh 需要 Node 做 SSH bootstrap + 抓 `MOSH CONNECT` + 换 PTY
(`moshHandshake` + `moshSession`)。**ET 不需要** —— `et` 客户端自己完成 SSH
引导 + 协议握手,我们只要把 `et` 当作普通 PTY 进程 `pty.spawn` 即可。所以**没有**
`etHandshake.cjs`。
- **凭证注入**:Mosh 自己驱动 ssh、直接往 PTY 里敲密码;ET 内部驱动 ssh,需用
**SSH_ASKPASS + 临时 ~/.ssh 环境**把保存的密码/密钥/跳板/算法喂给 et 内部的 ssh
(旧实现 `prepareEtSshEnvironment` 已完整实现,直接搬运)。
- **terminfo**:`et` 是纯传输客户端、本地不渲染终端,**无需** 捆绑 terminfo
(Mosh 因静态 ncurses 才需要)。打包目录里只放 `et[.exe]`(+ Windows DLL)。
- **构建系统**:Mosh 用 autotools;**ET 用 CMake + Ninja + vcpkg**
- **纯二进制**:MoshCatty 与理想 ET 打包都是「每平台一个客户端文件」。Mosh 侧已
无 terminfo / Cygwin DLL 袋;`et` 同样本地不渲染终端。Windows 若动态链 CRT
才考虑可选 DLL 目录,否则只放 `et[.exe]`。
- **构建系统**:Mosh 客户端在 **MoshCatty** 仓库用 Rust 构建并发布;Netcatty 只
`fetch`。**ET** 用 CMake + Ninja + vcpkg
(`cmake -DDISABLE_TELEMETRY=ON -GNinja -DCMAKE_BUILD_TYPE=RelWithDebInfo`),
产物是单个 `et`(Windows `et.exe`)。
产物是单个 `et`(Windows `et.exe`),由 `scripts/build-et/` + `build-et-binaries.yml` 发布。
## 命名约定(镜像 Mosh)
## 命名约定(镜像 Mosh / MoshCatty)
| Mosh | ET |
| Mosh (MoshCatty) | ET |
|------|----|
| `resources/mosh/<plat-arch>/mosh-client[.exe]` | `resources/et/<plat-arch>/et[.exe]` |
| 打包后 `<Resources>/mosh/mosh-client` | 打包后 `<Resources>/et/et` |
| `scripts/build-mosh/` | `scripts/build-et/` |
| 上游构建:`binaricat/MoshCatty` CI releases | `scripts/build-et/` + `build-et-binaries.yml` |
| `scripts/fetch-mosh-binaries.cjs` | `scripts/fetch-et-binaries.cjs` |
| `scripts/resolve-mosh-bin-release.cjs` | `scripts/resolve-et-bin-release.cjs` |
| `scripts/mosh-extra-resources.cjs` | `scripts/et-extra-resources.cjs` |
| env `MOSH_BIN_RELEASE` / 仓库 `Netcatty-mosh-bin` / tag `mosh-bin-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| env `MOSH_BIN_RELEASE` / 仓库 `MoshCatty` / tag `moshcatty-*` | env `ET_BIN_RELEASE` / 仓库 `Netcatty-et-bin` / tag `et-bin-*` |
| `npm run fetch:mosh[:dev]` | `npm run fetch:et[:dev]` |
| `bundledMoshClient()` / `resolveBareMoshClient()` | `bundledEtClient()` / `resolveBareEtClient()` |
| `.github/workflows/build-mosh-binaries.yml` | `.github/workflows/build-et-binaries.yml` |
---
@@ -52,7 +57,7 @@
`/resources/et/*/et-win32-*-dlls/`。保留 `resources/et/README.md`。
- [x] **1.3** `scripts/build-et/build-linux.sh` —— manylinux2014 + vcpkg 静态三元组
构建 `et`(x64/arm64),产物 `et-linux-<arch>.tar.gz`(+.sha256),内含单个 `et`。
校验非系统动态库(同 mosh 的 ldd 白名单)。
校验非系统动态库(ldd 白名单)。
- [x] **1.4** `scripts/build-et/build-macos.sh` —— arm64 + x86_64 分别构建后 `lipo`
成 universal,`MACOSX_DEPLOYMENT_TARGET=11.0`,产物 `et-darwin-universal.tar.gz`。
- [x] **1.5** `scripts/build-et/build-windows.ps1`(或 `.sh`)—— MSVC + vcpkg
@@ -60,13 +65,12 @@
则随附 DLL 目录 `et-win32-x64-dlls/`,否则纯静态无 DLL)。
- [x] **1.6** `scripts/et-extra-resources.cjs` —— 镜像 `mosh-extra-resources.cjs`:
按平台/arch 仅当 `resources/et/<plat-arch>/et[.exe]` 存在时才产出 extraResources
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。**去掉 terminfo 分支**。
指令(`to: "et/"`);Windows 额外处理可选 DLL 目录。纯客户端文件为主。
- [x] **1.7** `scripts/resolve-et-bin-release.cjs` —— 镜像 `resolve-mosh-bin-release.cjs`:
`TAG_RE=/^et-bin-.../`,默认仓库 `Netcatty-et-bin`,env `ET_BIN_RELEASE` 优先。
- [x] **1.8** `scripts/fetch-et-binaries.cjs` —— 镜像 `fetch-mosh-binaries.cjs`:
`TARGETS` 四项(linux-x64/arm64、darwin-universal、win32-x64),全部 tar.gz;
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**
(ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。
SHA256SUMS 校验;解包到 `resources/et/<plat-arch>/`。**Windows 用自建产物**。
- [x] **1.9** 单元测试:`scripts/fetch-et-binaries.test.cjs`、
`scripts/resolve-et-bin-release.test.cjs`、`scripts/et-extra-resources.test.cjs`
(镜像对应 mosh 测试,改名/改路径)。
@@ -77,13 +81,12 @@
`scripts/*.test.cjs`(确认即可)。
- [x] **1.11** `electron-builder.config.cjs`:引入 `etExtraResources`,在 darwin/win32/
linux 三处把 `etExtraResources(plat)` 合并进 `extraResources`(与 mosh 数组拼接)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 镜像
`build-mosh-binaries.yml`:四个构建 job + 一个 `release` job(dispatch 且
`release_tag` 非空时发布到 `Netcatty-et-bin`,附 `SHA256SUMS`)。`paths` 过滤
指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、`scripts/et-extra-resources.cjs`。
env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
- [x] **1.12** `.github/workflows/build-et-binaries.yml` —— 四个构建 job + 一个
`release` job(dispatch 且 `release_tag` 非空时发布到 `Netcatty-et-bin`,附
`SHA256SUMS`)。`paths` 过滤指向 `scripts/build-et/**`、`scripts/fetch-et-binaries.cjs`、
`scripts/et-extra-resources.cjs`。env 用 `ET_REF`(默认 ET release tag,如 `et-v6.2.x`)。
> 注:实际二进制由用户手动 `workflow_dispatch` 触发产出;本地/CI 未设
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh)。
> `ET_BIN_RELEASE` 时 fetch 步骤安静跳过(同 mosh 的 `MOSH_BIN_RELEASE`)。
## Phase 2 — 运行时定位捆绑客户端
@@ -100,7 +103,7 @@
`cleanupSessionExternalAuthArtifacts`、`execOnEtSession`、`startEtSession`。
**改动点**:`etCmd` 由 `findExecutable('et')` 改为 `resolveBareEtClient()`
(取捆绑二进制);找不到时抛错(同 mosh:提示跑 `npm run fetch:et:dev`)。
Windows 若有 DLL 目录,复用 `prependEnvPath` 思路把 DLL 目录加进 PATH。
Windows 若有动态链接 DLL 目录,可把该目录加进 PATH(MoshCatty 路径已无此需求)。
- [x] **3.2** `terminalBridge.cjs` 接线 `createEtSessionApi(ctx)`(镜像 moshSessionApi
的 ctx),传入 `bundledEtClient`、`tempDirBridge`、`execFile/execFileSync` 等;
解构出 `startEtSession`、`execOnEtSession`、`cleanupStaleEtTempDirs`、
+60
View File
@@ -0,0 +1,60 @@
# Native Cross-Platform Mosh Client
Status: **shipped via [MoshCatty](https://github.com/binaricat/MoshCatty)**
Related: [#2025](https://github.com/binaricat/Netcatty/issues/2025), [#2072](https://github.com/binaricat/Netcatty/issues/2072)
## Canonical repository
**https://github.com/binaricat/MoshCatty**
Netcatty only **consumes** `moshcatty-*` release binaries into `resources/mosh/`
via `scripts/fetch-mosh-binaries.cjs` / `scripts/resolve-mosh-bin-release.cjs`
(default `MOSH_BIN_REPO=MoshCatty`).
There is **no** in-tree Rust source, no Cygwin packaging path, and no
FluentTerminal / `mosh-bin-*` fallback.
## Integration contract
```text
MOSH_KEY=<key> mosh-client <host> <port>
```
Netcatty owns SSH bootstrap (`moshHandshake` + PTY), then swaps to the
bundled MoshCatty binary under `node-pty`.
| Concern | Owner |
|---------|--------|
| SSH auth / `MOSH CONNECT` parse | Netcatty Electron |
| UDP Mosh data plane | MoshCatty binary |
| Packaging / fetch / electron-builder | Netcatty scripts → MoshCatty releases |
## Why
Windows Cygwin `mosh-client` + partial runtime + ConPTY sandwich was
architecturally broken. MoshCatty is a pure Rust, wire-compatible client with
one code path on Linux / macOS / Windows (static CRT on Windows).
## Linux compatibility floors
MoshCatty Linux release binaries must target the **same glibc floors as
Netcatty package jobs** (not bare `ubuntu-latest`):
| Target | Netcatty package image | Max GLIBC |
|--------|------------------------|-----------|
| `linux-x64` | `almalinux:8` | 2.28 |
| `linux-arm64` | `debian:bullseye` | 2.31 |
Enforced upstream from `moshcatty-0.1.2` via MoshCatty release CI
(`scripts/assert-max-glibc.sh`). Do not pin packaging to pre-0.1.2 Linux
assets (they require GLIBC 2.34).
## Decision log
- **2026-07-10:** Feasibility accepted; client extracted to `binaricat/MoshCatty`.
- **2026-07-10:** Netcatty defaults packaging to MoshCatty releases.
- **2026-07-10:** Removed legacy Cygwin build pipeline, FluentTerminal fallback,
`mosh-bin-*` tags, dll/terminfo runtime helpers. Pure MoshCatty only
(`moshcatty-0.1.1`: ConPTY Ctrl+C + static MSVC CRT).
- **2026-07-10:** Require `moshcatty-0.1.2+` for Linux glibc floors matching
Netcatty (x64 ≤ 2.28, arm64 ≤ 2.31).
@@ -6,11 +6,8 @@ const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const {
addBundledMoshDllPath,
addBundledMoshRuntimeEnv,
addBundledMoshTerminfoEnv,
resolveBareMoshClient,
toCygwinPath,
} = require("./terminalBridge.cjs");
const { createMoshSessionApi } = require("./terminalBridge/moshSession.cjs");
@@ -96,149 +93,13 @@ test("mosh runtime does not fall back to system mosh or mosh-client", () => {
assert.equal(source.includes("brew install mosh"), false);
});
test("Windows dev mosh-client prepends the bundled DLL directory", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
const env = { Path: "C:\\Windows\\System32" };
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.Path.split(";")[0], dllDir);
});
test("Windows dev mosh-client updates the PATH key used by child process env", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
const env = {
Path: "C:\\Windows\\System32",
PATH: "C:\\Tools",
};
addBundledMoshDllPath(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.PATH.split(";")[0], dllDir);
assert.equal(Object.prototype.hasOwnProperty.call(env, "Path"), false);
});
test("Linux mosh-client prefers a sibling bundled terminfo dir", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
assert.equal(env.TERMINFO, terminfo);
const dirs = env.TERMINFO_DIRS.split(":");
assert.equal(dirs[0], terminfo);
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Linux mosh-client falls back to distro paths when no bundle present", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(client);
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
test("MoshCatty runtime env is a no-op (no DLL bag / terminfo)", () => {
const env = { Path: "C:\\Windows\\System32", TERM: "xterm-256color" };
const out = addBundledMoshRuntimeEnv(env, "C:\\app\\mosh-client.exe", { platform: "win32" });
assert.equal(out, env);
assert.equal(env.TERMINFO, undefined);
const dirs = env.TERMINFO_DIRS.split(":");
assert.ok(dirs.includes("/etc/terminfo"));
assert.ok(dirs.includes("/lib/terminfo"));
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Linux mosh-client merges caller-supplied TERMINFO_DIRS between bundle and system defaults", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = { TERMINFO_DIRS: "/home/user/.terminfo" };
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
const dirs = env.TERMINFO_DIRS.split(":");
assert.equal(dirs[0], terminfo);
assert.equal(dirs[1], "/home/user/.terminfo");
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Darwin mosh-client uses macOS-aware terminfo search paths", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "darwin-universal", "mosh-client");
writeExecutable(client);
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "darwin" });
const dirs = env.TERMINFO_DIRS.split(":");
assert.ok(dirs.includes("/usr/share/terminfo"));
assert.ok(dirs.includes("/opt/homebrew/share/terminfo"));
});
test("toCygwinPath converts Windows drive paths for Cygwin ncurses", () => {
assert.equal(
toCygwinPath("C:\\Program Files\\Netcatty\\resources\\mosh\\terminfo"),
"/cygdrive/c/Program Files/Netcatty/resources/mosh/terminfo",
);
assert.equal(
toCygwinPath("D:/Netcatty/resources/mosh/terminfo"),
"/cygdrive/d/Netcatty/resources/mosh/terminfo",
);
assert.equal(toCygwinPath("/already/posix"), "/already/posix");
});
test("Windows mosh-client points ncurses at bundled terminfo via Cygwin path", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "win32" });
// On macOS/Linux hosts the temp path is already POSIX, so toCygwinPath is a
// no-op. On Windows hosts it becomes /cygdrive/<drive>/.... Either way the
// value must not contain a drive-letter colon that would split TERMINFO_DIRS.
assert.equal(env.TERMINFO, toCygwinPath(terminfo));
assert.equal(env.TERMINFO_DIRS, env.TERMINFO);
assert.ok(!/[A-Za-z]:/.test(env.TERMINFO), "Cygwin TERMINFO must not contain a Windows drive letter");
});
test("Windows mosh runtime env includes DLL path and Cygwin terminfo", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
fs.mkdirSync(path.join(terminfo, "78"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "78", "xterm-256color"), "terminfo");
const env = { Path: "C:\\Windows\\System32" };
addBundledMoshRuntimeEnv(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.Path.split(";")[0], dllDir);
assert.equal(env.TERMINFO, toCygwinPath(terminfo));
assert.equal(env.TERMINFO_DIRS, env.TERMINFO);
assert.ok(!/[A-Za-z]:/.test(env.TERMINFO));
assert.equal(env.TERMINFO_DIRS, undefined);
assert.equal(env.Path, "C:\\Windows\\System32");
});
test("mosh UTF-8 decoder preserves fragmented Chinese output", () => {
@@ -273,3 +134,11 @@ test("removed Mosh client detection APIs are not exposed to the renderer", () =>
assert.equal(source.includes("netcatty:mosh:pickClient"), false);
}
});
test("Cygwin / terminfo helpers are gone from the mosh session module", () => {
const source = fs.readFileSync(path.join(__dirname, "terminalBridge", "moshSession.cjs"), "utf8");
assert.equal(source.includes("toCygwinPath"), false);
assert.equal(source.includes("findBundledMoshDllDir"), false);
assert.equal(source.includes("findBundledMoshTerminfoDir"), false);
assert.equal(source.includes("cygwin1"), false);
});
+1 -7
View File
@@ -580,10 +580,7 @@ const moshSessionApi = createMoshSessionApi({
});
const {
resolveBareMoshClient,
addBundledMoshDllPath,
addBundledMoshTerminfoEnv,
addBundledMoshRuntimeEnv,
toCygwinPath,
createMoshUtf8Decoder,
buildMoshSshAuthArgs,
cleanupMoshAuthTempFiles,
@@ -1444,7 +1441,7 @@ const { getDefaultShell, validatePath } = pathValidationApi;
/**
* Locate the mosh-client binary bundled by electron-builder via
* `extraResources` (see electron-builder.config.cjs and
* .github/workflows/build-mosh-binaries.yml).
* binaricat/MoshCatty releases).
*
* Returns an absolute path when the binary is on disk, otherwise null.
* In dev / non-packaged runs the path is computed against the project
@@ -1586,10 +1583,7 @@ module.exports = {
startMoshSession,
bundledMoshClient,
resolveBareMoshClient,
addBundledMoshDllPath,
addBundledMoshTerminfoEnv,
addBundledMoshRuntimeEnv,
toCygwinPath,
createMoshUtf8Decoder,
startEtSession,
execOnEtSession,
+3 -154
View File
@@ -25,158 +25,10 @@ function createMoshSessionApi(ctx) {
function resolveBareMoshClient(_options, opts = {}) {
return bundledMoshClient(opts);
}
function getEnvPathKey(env) {
const pathKeys = Object.keys(env).filter((key) => key.toLowerCase() === "path");
if (pathKeys.length === 0) return "PATH";
return pathKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))[0];
}
function getEnvPathDelimiter(opts = {}) {
return (opts.platform || process.platform) === "win32" ? ";" : path.delimiter;
}
function normalizeEnvPathPart(part, opts = {}) {
const pathApi = (opts.platform || process.platform) === "win32" ? path.win32 : path;
return pathApi.normalize(part).toLowerCase();
}
function prependEnvPath(env, dir, opts = {}) {
if (!dir) return env;
const pathKey = getEnvPathKey(env);
const duplicatePathKeys = Object.keys(env)
.filter((key) => key.toLowerCase() === "path" && key !== pathKey);
for (const key of duplicatePathKeys) {
delete env[key];
}
const current = env[pathKey] || "";
const delimiter = getEnvPathDelimiter(opts);
const parts = String(current).split(delimiter).filter(Boolean);
const normalizedDir = normalizeEnvPathPart(dir, opts);
if (!parts.some((part) => normalizeEnvPathPart(part, opts) === normalizedDir)) {
env[pathKey] = current ? `${dir}${delimiter}${current}` : dir;
}
return env;
}
function findBundledMoshDllDir(bareClient, opts = {}) {
const platform = opts.platform || process.platform;
if (platform !== "win32" || !bareClient) return null;
const clientDir = path.dirname(bareClient);
const arch = opts.arch || process.arch;
const preferred = path.join(clientDir, `mosh-client-win32-${arch}-dlls`);
if (fs.existsSync(preferred) && fs.statSync(preferred).isDirectory()) {
return preferred;
}
try {
const match = fs.readdirSync(clientDir)
.map((name) => path.join(clientDir, name))
.find((candidate) => {
const name = path.basename(candidate);
return /^mosh-client-win32-.+-dlls$/.test(name)
&& fs.existsSync(candidate)
&& fs.statSync(candidate).isDirectory();
});
return match || null;
} catch {
return null;
}
}
function addBundledMoshDllPath(env, bareClient, opts = {}) {
const dllDir = findBundledMoshDllDir(bareClient, opts);
return dllDir ? prependEnvPath(env, dllDir, opts) : env;
}
function findBundledMoshTerminfoDir(bareClient, _opts = {}) {
if (!bareClient) return null;
const terminfoDir = path.join(path.dirname(bareClient), "terminfo");
const hasXterm256 =
fs.existsSync(path.join(terminfoDir, "x", "xterm-256color")) ||
fs.existsSync(path.join(terminfoDir, "78", "xterm-256color"));
return hasXterm256 ? terminfoDir : null;
}
/**
* Convert a Windows path into the POSIX form Cygwin ncurses expects for
* TERMINFO / TERMINFO_DIRS. A raw `C:\...\terminfo` value is unusable:
* ncurses splits TERMINFO_DIRS on `:`, so the drive letter becomes a
* one-character bogus directory and the real path is never searched.
* Issue #2025.
*/
function toCygwinPath(winPath) {
if (typeof winPath !== "string" || !winPath) return winPath;
const normalized = winPath.replace(/\\/g, "/");
const drive = normalized.match(/^([A-Za-z]):(\/.*)?$/);
if (drive) {
const rest = drive[2] || "/";
return `/cygdrive/${drive[1].toLowerCase()}${rest}`;
}
if (normalized.startsWith("/")) return normalized;
return normalized;
}
// Standard locations where distros / package managers install the compiled
// terminfo database. Used as a fallback only — the bundled directory ships
// with the mosh release and is preferred. See issue #890 for context.
const LINUX_SYSTEM_TERMINFO_DIRS = [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
];
const DARWIN_SYSTEM_TERMINFO_DIRS = [
"/usr/share/terminfo",
"/opt/homebrew/share/terminfo",
"/usr/local/share/terminfo",
"/opt/local/share/terminfo",
];
function addBundledMoshTerminfoEnv(env, bareClient, opts = {}) {
const platform = opts.platform || process.platform;
const terminfoDir = findBundledMoshTerminfoDir(bareClient, opts);
if (platform === "win32") {
if (!terminfoDir) return env;
// Cygwin mosh-client reads these as POSIX paths. Keep the Windows
// form only when the caller already supplied a cygdrive path.
const cygTerminfo = toCygwinPath(terminfoDir);
env.TERMINFO = cygTerminfo;
env.TERMINFO_DIRS = cygTerminfo;
return env;
}
// POSIX. The bundled terminfo is the source of truth — our static
// ncurses' compiled-in default points at a build-time temp dir that no
// longer exists on the user's machine. Fall back to standard distro
// paths when the bundle is absent (e.g. running against an older mosh
// binary release that pre-dates the bundle). A caller-supplied
// TERMINFO_DIRS is preserved between the bundle and the system defaults.
const existing = (typeof env.TERMINFO_DIRS === "string" && env.TERMINFO_DIRS.length > 0)
? env.TERMINFO_DIRS.split(":").filter(Boolean)
: [];
const systemDirs = platform === "darwin" ? DARWIN_SYSTEM_TERMINFO_DIRS : LINUX_SYSTEM_TERMINFO_DIRS;
const dirs = [];
if (terminfoDir) dirs.push(terminfoDir);
for (const dir of existing) {
if (!dirs.includes(dir)) dirs.push(dir);
}
for (const dir of systemDirs) {
if (!dirs.includes(dir)) dirs.push(dir);
}
if (terminfoDir) {
env.TERMINFO = terminfoDir;
}
env.TERMINFO_DIRS = dirs.join(":");
return env;
}
function addBundledMoshRuntimeEnv(env, bareClient, opts = {}) {
addBundledMoshDllPath(env, bareClient, opts);
addBundledMoshTerminfoEnv(env, bareClient, opts);
// MoshCatty is a pure single binary (no Cygwin DLL bag, no terminfo).
// Runtime env only needs MOSH_KEY / TERM / LANG from the handshake path.
function addBundledMoshRuntimeEnv(env, _bareClient, _opts = {}) {
return env;
}
@@ -790,10 +642,7 @@ function createMoshSessionApi(ctx) {
return {
resolveBareMoshClient,
addBundledMoshDllPath,
addBundledMoshTerminfoEnv,
addBundledMoshRuntimeEnv,
toCygwinPath,
createMoshUtf8Decoder,
buildMoshSshAuthArgs,
cleanupMoshAuthTempFiles,
+1 -1
View File
@@ -6,7 +6,7 @@ with the Netcatty installer. Netcatty launches this bundled `et` directly
own SSH bootstrap and EternalTerminal protocol handshake against the remote
`etserver` / `etterminal`.
Unlike `mosh-client`, `et` is a pure network-transport client and does not
Like MoshCatty `mosh-client`, `et` is a pure network-transport client and does not
render a terminal locally, so there is **no terminfo bundle** here — only the
single `et` (`et.exe` on Windows) binary.
+44 -96
View File
@@ -1,109 +1,57 @@
# Bundled `mosh-client`
# Bundled `mosh-client` (MoshCatty)
This directory holds the network-protocol-only `mosh-client` binary
bundled with the Netcatty installer. Netcatty drives the `ssh` +
`mosh-server` bootstrap itself and then launches this bundled client
directly (see `electron/bridges/moshHandshake.cjs` and
`electron/bridges/terminalBridge.cjs`).
This directory holds the pure Rust `mosh-client` from
[binaricat/MoshCatty](https://github.com/binaricat/MoshCatty).
## How binaries land here
Netcatty runs SSH + `mosh-server` bootstrap itself, then launches this binary
(see `electron/bridges/moshHandshake.cjs` and `terminalBridge/moshSession.cjs`).
1. `.github/workflows/build-mosh-binaries.yml` builds or fetches
`mosh-client` on relevant pushes/PRs, or on a manual
`workflow_dispatch`. It uses `scripts/build-mosh/build-linux.sh` and
`scripts/build-mosh/build-macos.sh` for Linux/macOS, and
`scripts/build-mosh/fetch-windows.sh` for the pinned Windows bundle:
## Layout
| target | provenance |
|-------------------|-----------------------------------------------------------------|
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
| `win32-x64` | Netcatty-pinned runtime bundle + FluentTerminal fallback |
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
| Target | Release asset | Local path |
|--------|---------------|------------|
| Linux x64 | `mosh-client-linux-x64.tar.gz` | `linux-x64/mosh-client` |
| Linux arm64 | `mosh-client-linux-arm64.tar.gz` | `linux-arm64/mosh-client` |
| macOS universal | `mosh-client-darwin-universal.tar.gz` | `darwin-universal/mosh-client` |
| Windows x64 | `mosh-client-win32-x64.tar.gz` | `win32-x64/mosh-client.exe` |
The upstream Cygwin Windows build is not rebuilt in this workflow by
default. Windows releases use a SHA256-pinned bundle that was built
by Netcatty and verified for packaging.
Each tarball contains **only** the client binary (no Cygwin DLLs, no terminfo).
Windows builds static-link the MSVC CRT (`moshcatty-0.1.1+`).
2. When manually dispatched with `release_tag`, that workflow publishes
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
repository. The release gets a tag like `mosh-bin-1.4.0-1`, with
`SHA256SUMS` attached.
Release tags: `moshcatty-*` (prefer `moshcatty-0.1.2+`) from
`binaricat/MoshCatty`, with `SHA256SUMS`.
3. Release packaging runs `scripts/resolve-mosh-bin-release.cjs` before
`npm run fetch:mosh`. It uses an explicit workflow input first, then
the `MOSH_BIN_RELEASE` repository variable, then the latest
non-draft `mosh-bin-*` GitHub Release from the dedicated binary
repository. The fetch step pulls the binaries into
`resources/mosh/<platform-arch>/`. For local packaging, set
`MOSH_BIN_RELEASE` yourself before running the same fetch command.
Override `MOSH_BIN_OWNER` / `MOSH_BIN_REPO` only when testing a
different binary repository. `electron-builder.config.cjs` then
copies the matching binary into `Resources/mosh/mosh-client[.exe]`.
### Linux glibc floors
Local dev uses the same binary path: `npm run dev` runs
`npm run fetch:mosh:dev` first, which downloads the host platform's
bundled `mosh-client` into this gitignored directory. Netcatty does
not fall back to a system-installed `mosh` or `mosh-client`; if the
bundled binary is missing, Mosh startup fails loudly instead of using
whatever happens to be installed on the developer machine.
Linux assets must start on the same distros Netcatty packages for. From
`moshcatty-0.1.2`, MoshCatty builds Linux clients on:
Official Windows package builds currently ship x64 only for bundled
Mosh coverage. Windows arm64 packaging should be added only after we
have a tested arm64 client bundle.
| Target | Build image | Max required GLIBC |
|--------|-------------|--------------------|
| `linux-x64` | AlmaLinux 8 | 2.28 |
| `linux-arm64` | Debian bullseye | 2.31 |
The directory is otherwise empty (binaries are gitignored).
Do **not** pin packaging to `moshcatty-0.1.0` / `0.1.1` Linux binaries: those
were built on Ubuntu runners and require GLIBC 2.34.
## Fetch
```sh
# Optional pin (prefer 0.1.2+ for Linux glibc floors)
export MOSH_BIN_RELEASE=moshcatty-0.1.2
npm run fetch:mosh
# Dev: host platform; resolves latest moshcatty-* if unset
npm run fetch:mosh:dev
```
Env: `MOSH_BIN_OWNER` / `MOSH_BIN_REPO` (default `binaricat` / `MoshCatty`),
`MOSH_BIN_BASE_URL` for mirrors.
`electron-builder` packages `Resources/mosh/mosh-client[.exe]` only.
## Licenses
- Mosh itself is licensed under **GPL-3.0**
(https://github.com/mobile-shell/mosh).
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
is permitted.
- The default Windows x64 artifact is a SHA256-pinned Netcatty
`mosh-client-win32-x64.tar.gz` bundle from
`binaricat/Netcatty-mosh-bin` release `mosh-bin-1.4.0-2`. It includes
`mosh-client.exe`, required Cygwin runtime DLLs, and the bundled
`xterm-256color` terminfo entry. The FluentTerminal standalone
`mosh-client.exe` from https://github.com/felixse/FluentTerminal @
commit `bad0f85` remains published as a pinned fallback.
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
ncurses MIT) are compatible with GPL-3.0.
## Reproducible build
To reproduce the binaries locally:
```sh
docker run --rm -v $PWD:/workspace -w /workspace \
-e MOSH_REF=mosh-1.4.0 -e ARCH=x64 -e OUT_DIR=/workspace/out \
quay.io/pypa/manylinux2014_x86_64 \
bash scripts/build-mosh/build-linux.sh
```
For macOS the build needs an Xcode toolchain; see
`scripts/build-mosh/build-macos.sh`.
## Phase 2/3 — done in this PR
- `electron/bridges/moshHandshake.cjs` reimplements the upstream Mosh
Perl wrapper in Node: parser + sniffer + command builders as pure
functions.
- `terminalBridge.startMoshSession` runs the SSH bootstrap in a
node-pty so password / 2FA / known-hosts prompts render naturally
in the user's terminal, then swaps `session.proc` from the ssh PTY
to a freshly-spawned `mosh-client` PTY when `MOSH CONNECT` is
detected. Keystrokes that arrive after the swap go to mosh-client
because `writeToSession` reads `session.proc` lazily.
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
`ssh` client for the remote bootstrap. System-installed `mosh` /
`mosh-client` binaries are intentionally ignored.
- Windows x64 currently ships the pinned Netcatty runtime bundle. The
old standalone client remains only as a release fallback.
## Roadmap
- Add Windows arm64 only after a tested arm64 client bundle is
available.
- Make `MOSH_REF` track upstream release tags automatically.
- MoshCatty client: **GPL-3.0-or-later**
- Upstream Mosh protocol reference: **GPL-3.0**
- Netcatty is **GPL-3.0**
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env bash
# Build a portable mosh-client binary inside manylinux2014.
#
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
# OUT_DIR — directory to write mosh-client-linux-<arch>.tar.gz + sha256
#
# Output:
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz (binary + terminfo bundle)
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz.sha256
#
# The bundle ships a private terminfo database next to the binary because
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
# at the build-time prefix (a temp dir). Without bundling, mosh-client on
# distros lacking /usr/share/terminfo (or stripped containers) fails with
# "Terminfo database could not be found." See issue #890.
#
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
# scratch prefix, then build mosh against those and link libstdc++/libgcc
# statically. The resulting binary still depends on standard Linux system
# libraries such as glibc/libz/libutil from the manylinux2014 baseline
# (compatible with virtually every distro released since 2014, including
# Debian 9+, Ubuntu 18.04+, CentOS 7+).
set -euo pipefail
: "${MOSH_REF:?missing MOSH_REF}"
: "${ARCH:?missing ARCH}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_mosh_ref() {
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$MOSH_REF" == *..* ]] \
|| [[ "$MOSH_REF" == *@\{* ]] \
|| [[ "$MOSH_REF" == */ ]] \
|| [[ "$MOSH_REF" == *.lock ]]; then
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
exit 1
fi
}
validate_mosh_ref
OPENSSL_VER=3.0.13
PROTOBUF_VER=21.12
NCURSES_VER=6.4
curl_retry() {
local url="$1"
local dest="$2"
curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 "$url" -o "$dest"
}
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
PREFIX="$WORK/prefix"
mkdir -p "$PREFIX/lib" "$PREFIX/include" "$OUT_DIR"
yum install -y -q autoconf automake libtool perl perl-IPC-Cmd make gcc gcc-c++ pkgconfig zlib-devel
cd "$WORK"
# OpenSSL static
curl_retry "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" openssl.tgz
tar xzf openssl.tgz
( cd "openssl-$OPENSSL_VER"
./config no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl"
make -j"$(nproc)"
make install_sw )
# protobuf static (3.x stays compatible with mosh's generated proto code)
curl_retry "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" protobuf.tgz
tar xzf protobuf.tgz
( cd "protobuf-3.$PROTOBUF_VER"
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic
make -j"$(nproc)"
make install )
# ncurses static
curl_retry "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" ncurses.tgz
tar xzf ncurses.tgz
( cd "ncurses-$NCURSES_VER"
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" \
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec
make -j"$(nproc)"
make install )
# Mosh. Fetch the requested ref explicitly so branch names, tags, and commit
# SHAs all work from workflow_dispatch.
git init mosh
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
git -C mosh fetch --depth 1 origin "$MOSH_REF"
git -C mosh checkout --detach FETCH_HEAD
( cd mosh
export PATH="$PREFIX/bin:$PATH"
./autogen.sh
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig:$PREFIX/lib64/pkgconfig" \
./configure --enable-completion=no --disable-server \
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw -O2" \
LDFLAGS="-L$PREFIX/lib -L$PREFIX/lib64 -static-libstdc++ -static-libgcc" \
LIBS="-ldl -lpthread"
make -j"$(nproc)" )
BUNDLE_DIR="$WORK/linux-$ARCH-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/mosh-client"
cp mosh/src/frontend/mosh-client "$OUT_BIN"
strip "$OUT_BIN"
echo "--- file ---"
file "$OUT_BIN"
echo "--- ldd ---"
ldd "$OUT_BIN" || true
echo "--- size ---"
ls -lh "$OUT_BIN"
# Sanity check: must not link any non-system shared libraries. Allow only
# the glibc runtime family and the ELF loader.
ldd "$OUT_BIN" > "$WORK/ldd.txt" || true
awk '
/=>/ { print $1; next }
/^[[:space:]]*\/.*ld-linux/ { print $1; next }
' "$WORK/ldd.txt" > "$WORK/deps.txt"
if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]+|/lib.*/ld-linux.*\.so\.[0-9]+|ld-linux.*\.so\.[0-9]+)$' "$WORK/deps.txt"; then
echo "ERROR: mosh-client links a non-system shared library; static linking failed." >&2
exit 1
fi
# Bundle the terminfo entries our statically-linked ncurses needs. The
# ncurses `make install` above populated $PREFIX/share/terminfo/ with the
# full upstream terminfo.src. Ship a curated subset so users hit a
# working entry regardless of TERM.
TERMINFO_SRC="$PREFIX/share/terminfo"
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
mkdir -p "$TERMINFO_OUT"
copy_terminfo_entry() {
local name="$1"
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
[ -f "$src" ] || continue
local rel
rel=$(basename "$(dirname "$src")")
mkdir -p "$TERMINFO_OUT/$rel"
cp "$src" "$TERMINFO_OUT/$rel/$name"
return 0
done
return 1
}
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb linux; do
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
done
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (linux-$ARCH)." >&2
exit 1
fi
echo "--- bundled terminfo ---"
find "$TERMINFO_OUT" -type f -print
BUNDLE_TGZ="$OUT_DIR/mosh-client-linux-$ARCH.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH.tar.gz" > "mosh-client-linux-$ARCH.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-linux-$ARCH.tar.gz.sha256"
-214
View File
@@ -1,214 +0,0 @@
#!/usr/bin/env bash
# Build a universal2 (arm64 + x86_64) mosh-client for macOS.
#
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh
# OUT_DIR — destination directory
# MACOSX_DEPLOYMENT_TARGET — minimum macOS version (default 11.0)
#
# Output:
# $OUT_DIR/mosh-client-darwin-universal.tar.gz (binary + terminfo bundle)
# $OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256
#
# The bundle ships a private terminfo database next to the binary because
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
# at the build-time prefix (a temp dir). Without bundling, mosh-client
# fails with "Terminfo database could not be found." See issue #890.
#
# Strategy: build OpenSSL/protobuf/ncurses for arm64 and x86_64
# (cross-compile via Apple clang's -arch flag), link mosh-client per arch,
# then lipo the two single-arch binaries into one universal binary. The
# final binary is allowed to depend only on macOS system dylibs.
set -euo pipefail
: "${MOSH_REF:?missing MOSH_REF}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_mosh_ref() {
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$MOSH_REF" == *..* ]] \
|| [[ "$MOSH_REF" == *@\{* ]] \
|| [[ "$MOSH_REF" == */ ]] \
|| [[ "$MOSH_REF" == *.lock ]]; then
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
exit 1
fi
}
validate_mosh_ref
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
OPENSSL_VER=3.0.13
PROTOBUF_VER=21.12
NCURSES_VER=6.4
curl_retry() {
local url="$1"
local dest="$2"
curl -fsSL --retry 8 --retry-delay 5 --retry-max-time 600 "$url" -o "$dest"
}
# Install build tools when they are not already present on the runner.
brew list autoconf >/dev/null 2>&1 || brew install autoconf
brew list automake >/dev/null 2>&1 || brew install automake
brew list pkg-config >/dev/null 2>&1 || brew install pkg-config
brew list libtool >/dev/null 2>&1 || brew install libtool
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$OUT_DIR"
NATIVE_PROTOC_DIR=""
# Pre-fetch sources once.
cd "$WORK"
curl_retry "https://www.openssl.org/source/openssl-$OPENSSL_VER.tar.gz" openssl.tgz
curl_retry "https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_VER/protobuf-cpp-3.$PROTOBUF_VER.tar.gz" protobuf.tgz
curl_retry "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz" ncurses.tgz
git init mosh-src
git -C mosh-src remote add origin https://github.com/mobile-shell/mosh.git
git -C mosh-src fetch --depth 1 origin "$MOSH_REF"
git -C mosh-src checkout --detach FETCH_HEAD
build_arch() {
local ARCH="$1"
local TRIPLE
case "$ARCH" in
arm64) TRIPLE=aarch64-apple-darwin ;;
x86_64) TRIPLE=x86_64-apple-darwin ;;
*) echo "unknown arch: $ARCH" >&2; exit 1 ;;
esac
local PREFIX="$WORK/prefix-$ARCH"
mkdir -p "$PREFIX"
local CFLAGS_COMMON="-arch $ARCH -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET -O2"
local LDFLAGS_COMMON="-arch $ARCH -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET"
# OpenSSL
rm -rf "openssl-$OPENSSL_VER"
tar xf openssl.tgz
( cd "openssl-$OPENSSL_VER"
if [ "$ARCH" = "arm64" ]; then
./Configure darwin64-arm64-cc no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl" -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET
else
./Configure darwin64-x86_64-cc no-shared no-tests --prefix="$PREFIX" --openssldir="$PREFIX/ssl" -mmacosx-version-min=$MACOSX_DEPLOYMENT_TARGET
fi
make -j"$(sysctl -n hw.ncpu)"
make install_sw )
# protobuf
rm -rf "protobuf-3.$PROTOBUF_VER"
tar xf protobuf.tgz
( cd "protobuf-3.$PROTOBUF_VER"
./configure --prefix="$PREFIX" --enable-static --disable-shared --with-pic --host="$TRIPLE" \
CXX="clang++" CC="clang" \
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
# protoc must run on the host (not the cross-target) — but here host arch is one of the two,
# so this works directly when ARCH matches the runner. For the *other* arch we reuse the
# protoc compiled in the first pass via PATH.
make -j"$(sysctl -n hw.ncpu)" || make -j1
make install )
if [ "$ARCH" = "$NATIVE_ARCH" ]; then
NATIVE_PROTOC_DIR="$PREFIX/bin"
fi
# ncurses
rm -rf "ncurses-$NCURSES_VER"
tar xf ncurses.tgz
( cd "ncurses-$NCURSES_VER"
./configure --prefix="$PREFIX" --without-shared --without-debug --without-cxx-shared --without-tests --disable-pc-files --enable-widec --host="$TRIPLE" \
CC="clang" CXX="clang++" \
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
make -j"$(sysctl -n hw.ncpu)"
make -C include install
make -C ncurses install
# Compile + install the terminfo database for the native arch only.
# `tic` runs on the build host, so a cross-target build can't compile
# terminfo entries — but the .src database is arch-independent, so a
# single native-arch install populates $PREFIX/share/terminfo/ with
# the data we bundle below.
if [ "$ARCH" = "$NATIVE_ARCH" ]; then
make -C progs install
make -C misc install
fi )
# mosh per-arch build
( cd mosh-src
make distclean >/dev/null 2>&1 || true
export PATH="${NATIVE_PROTOC_DIR:-$PREFIX/bin}:$PATH"
./autogen.sh
PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" \
./configure --enable-completion=no --disable-server --host="$TRIPLE" \
CXX="clang++" CC="clang" \
CPPFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw" \
CXXFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw $CFLAGS_COMMON" \
CFLAGS="-I$PREFIX/include -I$PREFIX/include/ncursesw $CFLAGS_COMMON" \
LDFLAGS="-L$PREFIX/lib $LDFLAGS_COMMON"
make -j"$(sysctl -n hw.ncpu)"
cp src/frontend/mosh-client "$WORK/mosh-client-$ARCH" )
}
# Build host arch first so the first protobuf pass can use a native protoc.
NATIVE_ARCH=$(uname -m)
if [ "$NATIVE_ARCH" = "arm64" ]; then
build_arch arm64
build_arch x86_64
else
build_arch x86_64
build_arch arm64
fi
BUNDLE_DIR="$WORK/darwin-universal-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/mosh-client"
lipo -create "$WORK/mosh-client-arm64" "$WORK/mosh-client-x86_64" -output "$OUT_BIN"
strip -x "$OUT_BIN" || true
echo "--- file ---"
file "$OUT_BIN"
echo "--- otool -L ---"
otool -L "$OUT_BIN"
echo "--- lipo -info ---"
lipo -info "$OUT_BIN"
echo "--- size ---"
ls -lh "$OUT_BIN"
# Sanity check: must not depend on non-system dylibs.
if otool -L "$OUT_BIN" | tail -n +2 | awk '{print $1}' | grep -Ev "^(/usr/lib/|/System/)"; then
echo "ERROR: mosh-client links a non-system dylib; static linking failed." >&2
exit 1
fi
# Bundle the terminfo entries our statically-linked ncurses needs. Pull
# from the native-arch prefix where `make -C misc install` ran tic above.
TERMINFO_SRC="$WORK/prefix-$NATIVE_ARCH/share/terminfo"
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
mkdir -p "$TERMINFO_OUT"
copy_terminfo_entry() {
local name="$1"
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
[ -f "$src" ] || continue
local rel
rel=$(basename "$(dirname "$src")")
mkdir -p "$TERMINFO_OUT/$rel"
cp "$src" "$TERMINFO_OUT/$rel/$name"
return 0
done
return 1
}
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb; do
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
done
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (darwin-universal)." >&2
exit 1
fi
echo "--- bundled terminfo ---"
find "$TERMINFO_OUT" -type f -print
BUNDLE_TGZ="$OUT_DIR/mosh-client-darwin-universal.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal.tar.gz" > "mosh-client-darwin-universal.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256"
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Source: pin the last known-good Windows mosh bundle built by Netcatty's
# Cygwin workflow. The bundle carries mosh-client.exe, runtime DLLs, and the
# xterm-256color terminfo entry needed by packaged Windows builds.
#
# Keep the old FluentTerminal standalone exe as a release fallback. It is
# PE32+ x86-64 with no cygwin1.dll dependency. FluentTerminal is GPL-3.0, same
# license as Netcatty, and the binary itself is GPL-3.0 from upstream
# mobile-shell/mosh.
#
# Inputs (env): OUT_DIR
# Output: $OUT_DIR/mosh-client-win32-x64.tar.gz (+ .sha256)
# $OUT_DIR/mosh-client-win32-x64.exe (+ .sha256 fallback)
set -euo pipefail
: "${OUT_DIR:?missing OUT_DIR}"
WINDOWS_BUNDLE_URL="${WINDOWS_BUNDLE_URL:-https://github.com/binaricat/Netcatty-mosh-bin/releases/download/mosh-bin-1.4.0-2/mosh-client-win32-x64.tar.gz}"
WINDOWS_BUNDLE_SHA256="${WINDOWS_BUNDLE_SHA256:-3d4c4ae9fc8026dc8f4972856b9dedfb5e67fc623f2c23133c83b24c08bc1b2f}"
# Fallback pin: github.com/felixse/FluentTerminal commit bad0f85,
# Dependencies/MoshExecutables/x64/mosh-client.exe.
LEGACY_SOURCE_URL="${LEGACY_SOURCE_URL:-https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe}"
LEGACY_EXPECTED_SHA256="${LEGACY_EXPECTED_SHA256:-5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286}"
check_sha256() {
local file="$1"
local expected="$2"
local label="$3"
local actual
actual=$(sha256sum "$file" | awk '{print $1}')
if [ "$actual" != "$expected" ]; then
echo "ERROR: SHA256 mismatch for $label" >&2
echo " expected: $expected" >&2
echo " actual: $actual" >&2
exit 1
fi
printf '%s' "$actual"
}
mkdir -p "$OUT_DIR"
BUNDLE_OUT="$OUT_DIR/mosh-client-win32-x64.tar.gz"
LEGACY_OUT="$OUT_DIR/mosh-client-win32-x64.exe"
curl -fsSL "$WINDOWS_BUNDLE_URL" -o "$BUNDLE_OUT"
BUNDLE_ACTUAL=$(check_sha256 "$BUNDLE_OUT" "$WINDOWS_BUNDLE_SHA256" "mosh-client-win32-x64.tar.gz")
echo "Fetched mosh-client-win32-x64.tar.gz (sha256=$BUNDLE_ACTUAL)."
ls -lh "$BUNDLE_OUT"
echo "$BUNDLE_ACTUAL mosh-client-win32-x64.tar.gz" > "$BUNDLE_OUT.sha256"
cat "$BUNDLE_OUT.sha256"
curl -fsSL "$LEGACY_SOURCE_URL" -o "$LEGACY_OUT"
LEGACY_ACTUAL=$(check_sha256 "$LEGACY_OUT" "$LEGACY_EXPECTED_SHA256" "mosh-client.exe")
echo "Fetched fallback mosh-client.exe (sha256=$LEGACY_ACTUAL)."
ls -lh "$LEGACY_OUT"
echo "$LEGACY_ACTUAL mosh-client-win32-x64.exe" > "$LEGACY_OUT.sha256"
cat "$LEGACY_OUT.sha256"
+78 -177
View File
@@ -1,33 +1,28 @@
#!/usr/bin/env node
/* eslint-disable no-console */
//
// Download platform-specific mosh-client binaries built by the
// `build-mosh-binaries` GitHub Actions workflow into resources/mosh/, so
// electron-builder can bundle them via `extraResources`. Designed to be
// idempotent and safe to skip in dev / CI matrix legs that don't ship
// mosh (e.g. when MOSH_BIN_RELEASE is unset).
// Download platform-specific mosh-client binaries from binaricat/MoshCatty
// releases into resources/mosh/, so electron-builder can bundle them via
// extraResources.
//
// Layout (MoshCatty only — pure single binary per platform, no Cygwin DLLs,
// no terminfo bag):
// mosh-client-linux-x64.tar.gz -> resources/mosh/linux-x64/mosh-client
// mosh-client-linux-arm64.tar.gz -> resources/mosh/linux-arm64/mosh-client
// mosh-client-darwin-universal.tar.gz -> resources/mosh/darwin-universal/mosh-client
// mosh-client-win32-x64.tar.gz -> resources/mosh/win32-x64/mosh-client.exe
//
// Usage:
// node scripts/fetch-mosh-binaries.cjs # all platforms
// node scripts/fetch-mosh-binaries.cjs
// node scripts/fetch-mosh-binaries.cjs --platform=darwin --arch=universal
// node scripts/fetch-mosh-binaries.cjs --host --resolve-release
//
// Env knobs:
// MOSH_BIN_RELEASE — release tag in ${MOSH_BIN_OWNER}/${MOSH_BIN_REPO}.
// Skip the whole step if unset (printed as a notice
// so the build doesn't silently miss the bundling).
// MOSH_BIN_OWNER — defaults to the GITHUB_REPOSITORY owner, or 'binaricat'
// MOSH_BIN_REPO — default 'Netcatty-mosh-bin' (a dedicated binary
// repository so the client repo stays source-only).
// MOSH_BIN_BASE_URL — full override (e.g. for staging / local mirror).
// MOSH_BIN_RES_DIR — override output dir for tests.
// MOSH_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
// without SHA256SUMS. Never use for release builds.
// MOSH_BIN_FORCE_WINDOWS_CYGWIN=true — legacy debug knob kept for older
// automation. Windows now prefers the released bundle
// with its runtime helpers when SHA256SUMS lists it.
// MOSH_BIN_WINDOWS_LEGACY_URL / MOSH_BIN_WINDOWS_LEGACY_SHA256 — test/mirror
// overrides for that pinned Windows fallback.
// Env:
// MOSH_BIN_RELEASE — required for fetch unless --resolve-release
// MOSH_BIN_OWNER / MOSH_BIN_REPO — default binaricat / MoshCatty
// MOSH_BIN_BASE_URL — full release download base override
// MOSH_BIN_RES_DIR — output dir override (tests)
// MOSH_BIN_ALLOW_UNVERIFIED — accept missing SHA256SUMS (local mirrors only)
const fs = require("node:fs");
const path = require("node:path");
@@ -36,90 +31,33 @@ const https = require("node:https");
const os = require("node:os");
const crypto = require("node:crypto");
const { execFileSync } = require("node:child_process");
const { main: resolveMoshBinRelease } = require("./resolve-mosh-bin-release.cjs");
const {
main: resolveMoshBinRelease,
validateReleaseTag,
} = require("./resolve-mosh-bin-release.cjs");
const ROOT = path.resolve(__dirname, "..");
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
const WINDOWS_LEGACY_FLUENT_MOSH_CLIENT = {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe",
sha256: "5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286",
};
// (file basename in the release -> relative subpath under resources/mosh/)
// Using flat names in the release for SHA256SUMS readability, then
// fanning out into platform-arch subdirs locally.
//
// Linux/macOS/Windows bundle targets are tar.gz archives containing the
// binary plus the runtime helpers each platform needs.
// Bundling terminfo lets bundled Posix mosh-client builds work on
// minimal hosts that don't have a
// system ncurses-base — see issue #890.
//
// `legacy` describes the pre-bundle artifact name some published mosh
// binary releases still ship (Linux/Darwin used flat files before the
// bundle layout). When SHA256SUMS lists only the legacy name we fall
// back to it so existing releases keep working until a new tag is
// republished with the bundle layout.
const TARGETS = [
{
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", binary: "mosh-client",
},
{
platform: "linux", arch: "arm64",
file: "mosh-client-linux-arm64.tar.gz", localDir: "linux-arm64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
file: "mosh-client-linux-arm64.tar.gz", localDir: "linux-arm64", binary: "mosh-client",
},
{
platform: "darwin", arch: "universal",
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz",
legacy: { file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", binary: "mosh-client",
},
{
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: WINDOWS_LEGACY_FLUENT_MOSH_CLIENT,
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", binary: "mosh-client.exe",
},
];
function applyReleaseAssetOverrides(asset, opts = {}) {
if (asset.id !== WINDOWS_LEGACY_FLUENT_MOSH_CLIENT.id) return asset;
return {
...asset,
url: opts.windowsLegacyUrl || asset.url,
sha256: opts.windowsLegacySha256 || asset.sha256,
};
}
function selectReleaseAsset(target, sums, opts = {}) {
const primary = { file: target.file, extract: target.extract, local: target.local, localDir: target.localDir };
if (!target.legacy) return primary;
if (target.preferLegacy && !opts.forceWindowsCygwin) {
const legacy = applyReleaseAssetOverrides(target.legacy, opts);
if (sums.get(target.legacy.file) === legacy.sha256) {
return { file: target.legacy.file, local: target.legacy.local, sha256: legacy.sha256 };
}
return legacy;
}
// SHA256SUMS unavailable (allowUnverified mirror) — keep the primary
// and let download / extraction errors surface naturally.
if (sums.size === 0) return primary;
if (sums.has(target.file)) return primary;
if (sums.has(target.legacy.file)) {
const expected = sums.get(target.legacy.file);
const fallback = applyReleaseAssetOverrides(target.legacy, opts);
if (fallback.id && fallback.sha256 && expected !== fallback.sha256) {
return fallback;
}
return { file: target.legacy.file, local: target.legacy.local, sha256: expected };
}
return primary;
}
function log(msg) { console.log(`[fetch-mosh-binaries] ${msg}`); }
function warn(msg) { console.warn(`[fetch-mosh-binaries] WARN ${msg}`); }
@@ -215,10 +153,11 @@ function chmodExecutable(filePath) {
}
function parseMoshBinRepository(env) {
const githubOwner = (env.GITHUB_REPOSITORY || "").split("/")[0];
// Canonical default binaricat/MoshCatty — never inherit fork owner from
// GITHUB_REPOSITORY (same policy as resolve-mosh-bin-release).
return {
owner: env.MOSH_BIN_OWNER || githubOwner || "binaricat",
repo: env.MOSH_BIN_REPO || "Netcatty-mosh-bin",
owner: env.MOSH_BIN_OWNER || "binaricat",
repo: env.MOSH_BIN_REPO || "MoshCatty",
};
}
@@ -252,53 +191,46 @@ function assertExtractedTreeSafe(root) {
}
}
function assertBundledTerminfo(extractDir, target) {
const terminfoDir = path.join(extractDir, "terminfo");
const terminfoEntry = [
path.join(terminfoDir, "x", "xterm-256color"),
path.join(terminfoDir, "78", "xterm-256color"),
].find((entry) => fs.existsSync(entry));
if (terminfoEntry && !fs.lstatSync(terminfoEntry).isFile()) {
throw new Error(`${target.file} contained invalid terminfo for xterm-256color`);
/** Keep only the pure MoshCatty client binary under extractDir. */
function normalizeMoshCattyBundle(extractDir, target) {
const wanted = target.binary;
const candidates = [
path.join(extractDir, wanted),
path.join(extractDir, `mosh-client-${target.platform}-${target.arch}${wanted.endsWith(".exe") ? ".exe" : ""}`),
path.join(extractDir, wanted.endsWith(".exe") ? "mosh-client.exe" : "mosh-client"),
];
let found = candidates.find((p) => fs.existsSync(p) && fs.lstatSync(p).isFile());
if (!found) {
// Search one level deep for a correctly named binary
for (const name of fs.readdirSync(extractDir)) {
const child = path.join(extractDir, name);
if (!fs.statSync(child).isDirectory()) continue;
const nested = path.join(child, wanted);
if (fs.existsSync(nested) && fs.lstatSync(nested).isFile()) {
found = nested;
break;
}
}
}
if (!terminfoEntry) {
warn(`${target.file} did not contain terminfo for xterm-256color; ${target.platform}-${target.arch} mosh packaging will fall back to host system terminfo (issue #890).`);
if (!found) {
throw new Error(`${target.file} did not contain ${wanted}`);
}
}
function normalizeWindowsBundle(extractDir, target) {
const genericExe = path.join(extractDir, "mosh-client.exe");
const legacyExe = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}.exe`);
if (!fs.existsSync(genericExe) && fs.existsSync(legacyExe)) {
fs.renameSync(legacyExe, genericExe);
}
if (!fs.existsSync(genericExe) || !fs.lstatSync(genericExe).isFile()) {
throw new Error(`${target.file} did not contain mosh-client.exe`);
}
const dllDir = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}-dlls`);
if (!fs.existsSync(dllDir) || !fs.statSync(dllDir).isDirectory()) {
throw new Error(`${target.file} did not contain ${path.basename(dllDir)}/`);
}
assertBundledTerminfo(extractDir, target);
chmodExecutable(genericExe);
}
// Stage a clean tree with only the client binary (drop any accidental extras).
const cleanDir = path.join(extractDir, ".moshcatty-clean");
fs.mkdirSync(cleanDir, { recursive: true });
const destBinary = path.join(cleanDir, wanted);
fs.copyFileSync(found, destBinary);
chmodExecutable(destBinary);
function normalizePosixBundle(extractDir, target) {
const binary = path.join(extractDir, "mosh-client");
const legacyBinary = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}`);
if (!fs.existsSync(binary) && fs.existsSync(legacyBinary)) {
fs.renameSync(legacyBinary, binary);
for (const name of fs.readdirSync(extractDir)) {
if (name === ".moshcatty-clean") continue;
fs.rmSync(path.join(extractDir, name), { recursive: true, force: true });
}
if (!fs.existsSync(binary) || !fs.lstatSync(binary).isFile()) {
throw new Error(`${target.file} did not contain mosh-client`);
for (const name of fs.readdirSync(cleanDir)) {
fs.renameSync(path.join(cleanDir, name), path.join(extractDir, name));
}
assertBundledTerminfo(extractDir, target);
chmodExecutable(binary);
}
function normalizeBundle(extractDir, target) {
if (target.platform === "win32") return normalizeWindowsBundle(extractDir, target);
return normalizePosixBundle(extractDir, target);
fs.rmSync(cleanDir, { recursive: true, force: true });
}
function replaceDir(srcDir, destDir) {
@@ -328,7 +260,7 @@ function unpackTarGz(buf, target, { resDir }) {
stdio: "inherit",
});
assertExtractedTreeSafe(extractDir);
normalizeBundle(extractDir, target);
normalizeMoshCattyBundle(extractDir, target);
replaceDir(extractDir, destDir);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -336,55 +268,30 @@ function unpackTarGz(buf, target, { resDir }) {
return destDir;
}
function writeFlatAsset(buf, target, asset, { resDir }) {
const dest = path.join(resDir, asset.local);
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-flat-"));
const tmpDest = path.join(tmpRoot, path.basename(dest));
try {
fs.writeFileSync(tmpDest, buf);
if (target.platform !== "win32") fs.chmodSync(tmpDest, 0o755);
replaceDir(tmpRoot, path.dirname(dest));
} catch (err) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
throw err;
}
return dest;
}
async function fetchOne(target, sums, opts) {
const { baseUrl, resDir, allowUnverified = false } = opts;
const asset = selectReleaseAsset(target, sums, opts);
if (asset.file !== target.file) {
log(`using legacy asset ${asset.file} for ${target.platform}-${target.arch}`);
}
const url = asset.url || `${baseUrl}/${asset.file}`;
const url = `${baseUrl}/${target.file}`;
let buf;
try {
buf = await follow(url);
} catch (err) {
throw new Error(`download failed for ${asset.file}: ${err.message}`);
throw new Error(`download failed for ${target.file}: ${err.message}`);
}
const expected = asset.sha256 || sums.get(asset.file);
const expected = sums.get(target.file);
const actual = crypto.createHash("sha256").update(buf).digest("hex");
if (expected && expected !== actual) {
throw new Error(`SHA256 mismatch for ${asset.file}: expected ${expected}, got ${actual}`);
throw new Error(`SHA256 mismatch for ${target.file}: expected ${expected}, got ${actual}`);
}
if (!expected) {
if (!allowUnverified) {
throw new Error(`no SHA256 entry for ${asset.file}`);
throw new Error(`no SHA256 entry for ${target.file}`);
}
warn(`no SHA256 entry for ${asset.file} - accepting actual ${actual}`);
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
}
if (asset.extract === "tar.gz") {
const destDir = unpackTarGz(buf, target, { resDir });
log(`unpacked ${asset.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
return true;
}
const dest = writeFlatAsset(buf, target, asset, { resDir });
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
const destDir = unpackTarGz(buf, target, { resDir });
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
return true;
}
@@ -406,16 +313,18 @@ async function main(argv = process.argv.slice(2), env = process.env) {
release = await resolveMoshBinRelease(env);
}
if (!release) {
log("MOSH_BIN_RELEASE is unset - skipping. Set it (e.g. mosh-bin-1.4.0-1) to bundle mosh-client into the package.");
log("MOSH_BIN_RELEASE is unset - skipping. Set it (e.g. moshcatty-0.1.2) to bundle mosh-client into the package.");
return 0;
}
// Reject pre-0.1.2 pins (Linux GLIBC 2.34) even when MOSH_BIN_RELEASE is set
// without going through --resolve-release.
release = validateReleaseTag(release);
const { owner, repo } = parseMoshBinRepository(env);
const baseUrl = env.MOSH_BIN_BASE_URL ||
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
const resDir = path.resolve(env.MOSH_BIN_RES_DIR || DEFAULT_RES_DIR);
const allowUnverified = env.MOSH_BIN_ALLOW_UNVERIFIED === "true";
const forceWindowsCygwin = env.MOSH_BIN_FORCE_WINDOWS_CYGWIN === "true";
const platformFilter = hostTarget?.platform || platformArg;
const archFilter = hostTarget?.arch || archArg;
@@ -427,14 +336,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
if (platformFilter && target.platform !== platformFilter) continue;
if (archFilter && target.arch !== archFilter) continue;
total += 1;
if (await fetchOne(target, sums, {
baseUrl,
resDir,
allowUnverified,
forceWindowsCygwin,
windowsLegacyUrl: env.MOSH_BIN_WINDOWS_LEGACY_URL,
windowsLegacySha256: env.MOSH_BIN_WINDOWS_LEGACY_SHA256,
})) ok += 1;
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
}
log(`done - ${ok}/${total} binaries written`);
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
@@ -455,10 +357,9 @@ module.exports = {
resolveHostTarget,
resolveTarArchiveInvocation,
parseSums,
selectReleaseAsset,
validateTarEntries,
assertExtractedTreeSafe,
normalizeMoshCattyBundle,
unpackTarGz,
writeFlatAsset,
main,
};
+110 -375
View File
@@ -15,7 +15,7 @@ const {
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
selectReleaseAsset,
TARGETS,
} = require("./fetch-mosh-binaries.cjs");
function makeTmp(t) {
@@ -40,35 +40,48 @@ function makeTarGz(t, entries) {
return fs.readFileSync(tarPath);
}
test("fetch-mosh-binaries defaults to the dedicated mosh binary repository", () => {
assert.deepEqual(parseMoshBinRepository({}), { owner: "binaricat", repo: "Netcatty-mosh-bin" });
async function serveAssets(t, assets) {
const server = http.createServer((req, res) => {
const name = decodeURIComponent(req.url.split("/").pop());
if (!Object.prototype.hasOwnProperty.call(assets, name)) {
res.writeHead(404);
res.end("missing");
return;
}
res.writeHead(200);
res.end(assets[name]);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => server.close());
return `http://127.0.0.1:${server.address().port}`;
}
test("fetch-mosh-binaries defaults to the MoshCatty binary repository", () => {
assert.deepEqual(parseMoshBinRepository({}), { owner: "binaricat", repo: "MoshCatty" });
// Fork CI must not inherit GITHUB_REPOSITORY owner for MoshCatty downloads.
assert.deepEqual(parseMoshBinRepository({ GITHUB_REPOSITORY: "owner/project" }), {
owner: "owner",
repo: "Netcatty-mosh-bin",
owner: "binaricat",
repo: "MoshCatty",
});
assert.deepEqual(
parseMoshBinRepository({ GITHUB_REPOSITORY: "owner/project", MOSH_BIN_OWNER: "bin", MOSH_BIN_REPO: "binaries" }),
{ owner: "bin", repo: "binaries" },
parseMoshBinRepository({ MOSH_BIN_OWNER: "other", MOSH_BIN_REPO: "fork-mosh" }),
{ owner: "other", repo: "fork-mosh" },
);
});
test("TARGETS are pure MoshCatty tarball assets only", () => {
for (const t of TARGETS) {
assert.match(t.file, /^mosh-client-.+\.tar\.gz$/);
assert.ok(t.binary === "mosh-client" || t.binary === "mosh-client.exe");
assert.equal(Object.prototype.hasOwnProperty.call(t, "legacy"), false);
}
});
test("resolveHostTarget maps the local platform to the bundled target", () => {
assert.deepEqual(resolveHostTarget({ platform: "darwin", arch: "arm64" }), {
platform: "darwin",
arch: "universal",
});
assert.deepEqual(resolveHostTarget({ platform: "darwin", arch: "x64" }), {
platform: "darwin",
arch: "universal",
});
assert.deepEqual(resolveHostTarget({ platform: "linux", arch: "x64" }), {
platform: "linux",
arch: "x64",
});
assert.deepEqual(resolveHostTarget({ platform: "linux", arch: "arm64" }), {
platform: "linux",
arch: "arm64",
});
assert.deepEqual(resolveHostTarget({ platform: "win32", arch: "x64" }), {
platform: "win32",
arch: "x64",
@@ -117,9 +130,7 @@ test("replaceDir falls back to copy when rename crosses devices", (t) => {
test("fetch-mosh-binaries host mode skips unsupported local targets", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const baseUrl = await serveAssets(t, {
SHA256SUMS: "",
});
const baseUrl = await serveAssets(t, { SHA256SUMS: "" });
const { stderr } = await execFileAsync(
process.execPath,
@@ -127,7 +138,7 @@ test("fetch-mosh-binaries host mode skips unsupported local targets", async (t)
{
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
@@ -140,49 +151,39 @@ test("fetch-mosh-binaries host mode skips unsupported local targets", async (t)
assert.equal(fs.existsSync(resDir), false);
});
test("fetch-mosh-binaries host mode skips unsupported targets before resolving release", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const { stdout, stderr } = await execFileAsync(
process.execPath,
[script, "--host", "--resolve-release", "--platform=win32", "--arch=arm64"],
{
env: {
...process.env,
MOSH_BIN_RELEASE: "",
MOSH_BIN_RELEASES_JSON: "[]",
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
},
);
assert.match(stderr, /No bundled mosh-client target for win32-arm64/);
assert.doesNotMatch(stdout, /MOSH_BIN_RELEASE is unset/);
assert.equal(fs.existsSync(resDir), false);
});
async function serveAssets(t, assets) {
const server = http.createServer((req, res) => {
const name = decodeURIComponent(req.url.split("/").pop());
if (!Object.prototype.hasOwnProperty.call(assets, name)) {
res.writeHead(404);
res.end("missing");
return;
}
res.writeHead(200);
res.end(assets[name]);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(() => server.close());
return `http://127.0.0.1:${server.address().port}`;
}
test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", async (t) => {
test("fetch-mosh-binaries unpacks pure Windows MoshCatty tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client-win32-x64.exe": "exe",
"mosh-client.exe": "pure-moshcatty-exe",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(
fs.readFileSync(path.join(resDir, "win32-x64", "mosh-client.exe"), "utf8"),
"pure-moshcatty-exe",
);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
});
test("fetch-mosh-binaries strips accidental dll/terminfo from Windows tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
"terminfo/x/xterm-256color": "terminfo",
});
@@ -194,216 +195,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll")), true);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries accepts legacy Windows bundles without terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
});
assert.match(stderr, /did not contain terminfo for xterm-256color/);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
});
test("fetch-mosh-binaries rejects invalid Windows terminfo entries", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const srcDir = makeTmp(t);
fs.writeFileSync(path.join(srcDir, "mosh-client.exe"), "exe");
fs.mkdirSync(path.join(srcDir, "mosh-client-win32-x64-dlls"), { recursive: true });
fs.writeFileSync(path.join(srcDir, "mosh-client-win32-x64-dlls", "cygwin1.dll"), "dll");
fs.mkdirSync(path.join(srcDir, "terminfo", "x", "xterm-256color"), { recursive: true });
const tarPath = path.join(makeTmp(t), "invalid-terminfo.tar.gz");
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "mosh-client-win32-x64-dlls", "terminfo"], { stdio: "pipe" });
const tar = fs.readFileSync(tarPath);
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
}),
/invalid terminfo for xterm-256color/,
);
});
test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(Buffer.from("other"))} other-file\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
}),
);
});
test("selectReleaseAsset prefers the bundled tarball when listed in SHA256SUMS", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
const sums = new Map([
["mosh-client-linux-x64.tar.gz", "abc"],
["mosh-client-linux-x64", "def"],
]);
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-linux-x64.tar.gz");
});
test("selectReleaseAsset falls back to the legacy flat asset when only it is published", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
const sums = new Map([["mosh-client-linux-x64", "def"]]);
const asset = selectReleaseAsset(target, sums);
assert.equal(asset.file, "mosh-client-linux-x64");
assert.equal(asset.local, "linux-x64/mosh-client");
assert.equal(asset.extract, undefined);
});
test("selectReleaseAsset stays on the primary when SHA256SUMS is empty (unverified mirror)", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
assert.equal(selectReleaseAsset(target, new Map()).file, "mosh-client-linux-x64.tar.gz");
});
test("selectReleaseAsset prefers the released Windows bundle by default", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
};
const sums = new Map([["mosh-client-win32-x64.tar.gz", "def"]]);
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-win32-x64.tar.gz");
assert.equal(selectReleaseAsset(target, sums, { forceWindowsCygwin: true }).file, "mosh-client-win32-x64.tar.gz");
});
test("selectReleaseAsset falls back to the Windows standalone asset when the bundle is absent", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
};
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "abc"]]));
assert.equal(asset.file, "mosh-client-win32-x64.exe");
assert.equal(asset.local, "win32-x64/mosh-client.exe");
assert.equal(asset.url, undefined);
assert.equal(asset.sha256, "abc");
});
test("selectReleaseAsset ignores a released Windows asset when its checksum is not the pinned standalone", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
};
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "def"]]));
assert.equal(asset.file, "mosh-client-win32-x64.exe");
assert.equal(asset.local, "win32-x64/mosh-client.exe");
assert.equal(asset.url, "https://example.test/mosh-client.exe");
assert.equal(asset.sha256, "abc");
});
test("fetch-mosh-binaries downloads the released Windows bundle by default", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
"terminfo/x/xterm-256color": "terminfo",
});
fs.mkdirSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls"), { recursive: true });
fs.writeFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "stale");
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
@@ -412,24 +204,24 @@ test("fetch-mosh-binaries downloads the released Windows bundle by default", asy
});
assert.equal(fs.readFileSync(path.join(resDir, "win32-x64", "mosh-client.exe"), "utf8"), "exe");
assert.equal(fs.readFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "utf8"), "dll");
assert.equal(fs.readFileSync(path.join(resDir, "win32-x64", "terminfo", "x", "xterm-256color"), "utf8"), "terminfo");
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
});
test("fetch-mosh-binaries falls back to the legacy flat asset for older releases", async (t) => {
test("fetch-mosh-binaries unpacks pure Linux MoshCatty tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const flat = Buffer.from("legacy-binary");
fs.mkdirSync(path.join(resDir, "linux-x64", "terminfo", "78"), { recursive: true });
fs.writeFileSync(path.join(resDir, "linux-x64", "terminfo", "78", "xterm-256color"), "stale");
const tar = makeTarGz(t, {
"mosh-client": "linux-client",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64": flat,
SHA256SUMS: `${sha256(flat)} mosh-client-linux-x64\n`,
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
@@ -437,92 +229,14 @@ test("fetch-mosh-binaries falls back to the legacy flat asset for older releases
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
assert.equal(fs.readFileSync(path.join(resDir, "linux-x64", "mosh-client"), "utf8"), "legacy-binary");
assert.equal(fs.readFileSync(path.join(resDir, "linux-x64", "mosh-client"), "utf8"), "linux-client");
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo")), false);
});
test("fetch-mosh-binaries unpacks the Linux tarball with bundled terminfo", async (t) => {
test("fetch-mosh-binaries rejects tarball without mosh-client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries warns when the Linux tarball lacks terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.match(stderr, /did not contain terminfo for xterm-256color/);
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
});
test("fetch-mosh-binaries unpacks the Darwin tarball with bundled terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-darwin-universal.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-darwin-universal.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=darwin", "--arch=universal"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "mosh-client")), true);
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries rejects a Linux tarball without mosh-client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"terminfo/x/xterm-256color": "terminfo",
"README.txt": "no binary here",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
@@ -533,7 +247,7 @@ test("fetch-mosh-binaries rejects a Linux tarball without mosh-client", async (t
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
@@ -544,14 +258,36 @@ test("fetch-mosh-binaries rejects a Linux tarball without mosh-client", async (t
);
});
test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: process.platform === "win32" }, async (t) => {
test("fetch-mosh-binaries fails when SHA256SUMS lacks the asset", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, { "mosh-client.exe": "exe" });
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(Buffer.from("other"))} other-file\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/no SHA256 entry/,
);
});
test("fetch-mosh-binaries rejects symlinks inside tarballs", { skip: process.platform === "win32" }, async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const srcDir = makeTmp(t);
fs.writeFileSync(path.join(srcDir, "outside.exe"), "outside");
fs.symlinkSync(path.join(srcDir, "outside.exe"), path.join(srcDir, "mosh-client.exe"));
fs.mkdirSync(path.join(srcDir, "mosh-client-win32-x64-dlls"));
fs.writeFileSync(path.join(srcDir, "mosh-client-win32-x64-dlls", "cygwin1.dll"), "dll");
fs.writeFileSync(path.join(srcDir, "mosh-client.exe"), "exe");
fs.symlinkSync("mosh-client.exe", path.join(srcDir, "link.exe"));
const tarPath = path.join(makeTmp(t), "symlink.tar.gz");
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "mosh-client-win32-x64-dlls"], { stdio: "pipe" });
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "link.exe"], { stdio: "pipe" });
const tar = fs.readFileSync(tarPath);
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
@@ -562,14 +298,13 @@ test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: pro
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/symbolic link|did not contain mosh-client\.exe/,
/symbolic link/,
);
});
-13
View File
@@ -7,8 +7,6 @@ const buildWorkflow = fs.readFileSync(
path.join(__dirname, "..", ".github", "workflows", "build.yml"),
"utf8",
);
const moshLinuxScript = fs.readFileSync(path.join(__dirname, "build-mosh", "build-linux.sh"), "utf8");
const moshMacScript = fs.readFileSync(path.join(__dirname, "build-mosh", "build-macos.sh"), "utf8");
const etLinuxScript = fs.readFileSync(path.join(__dirname, "build-et", "build-linux.sh"), "utf8");
const etMacScript = fs.readFileSync(path.join(__dirname, "build-et", "build-macos.sh"), "utf8");
@@ -134,17 +132,6 @@ test("build workflow builds Linux x64 native modules in a glibc 2.28 container",
);
});
test("mosh binary build scripts retry source downloads before failing CI", () => {
for (const [name, script] of [
["linux", moshLinuxScript],
["macos", moshMacScript],
]) {
assert.match(script, /curl_retry\(\)/, `${name} mosh build must use retrying downloads`);
assert.match(script, /--retry 8/, `${name} mosh build must retry transient HTTP failures`);
assert.match(script, /--retry-max-time 600/, `${name} mosh build must bound retry time`);
}
});
test("et binary build scripts retry dependency configure and pin ninja", () => {
for (const [name, script] of [
["linux", etLinuxScript],
+7 -44
View File
@@ -1,15 +1,7 @@
// Compute the platform-specific `extraResources` entry for bundling
// mosh-client. Lives under scripts/ (eslint-ignored) so it can use
// Node CommonJS globals freely; consumed from electron-builder.config.cjs.
//
// Binaries are produced by .github/workflows/build-mosh-binaries.yml and
// downloaded into resources/mosh/<platform-arch>/ by
// scripts/fetch-mosh-binaries.cjs (gated on MOSH_BIN_RELEASE).
//
// We only emit the directive when the binary is actually on disk so that
// `npm run pack` keeps working without bundled mosh — for example, when
// the developer skipped the fetch step or the relevant arch hasn't been
// built yet.
// Platform-specific electron-builder extraResources for the MoshCatty client.
// Binaries are downloaded from binaricat/MoshCatty into resources/mosh/ by
// scripts/fetch-mosh-binaries.cjs. Pure single-binary layout only.
const fs = require("node:fs");
const path = require("node:path");
@@ -21,10 +13,6 @@ function hasFile(file) {
return fs.existsSync(file) && fs.statSync(file).isFile();
}
function hasDir(dir) {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
}
function moshExtraResources(platform) {
const moshRoot = path.resolve(process.cwd(), "resources", "mosh");
if (!fs.existsSync(moshRoot)) return [];
@@ -32,52 +20,27 @@ function moshExtraResources(platform) {
if (platform === "darwin") {
const file = path.join(moshRoot, "darwin-universal", "mosh-client");
if (!hasFile(file)) return [];
const resources = [
return [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
];
const terminfoDir = path.join(moshRoot, "darwin-universal", "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
if (platform === "linux") {
const arch = requestedArch();
const file = path.join(moshRoot, `linux-${arch}`, "mosh-client");
if (!hasFile(file)) return [];
const resources = [
return [
{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] },
];
const terminfoDir = path.join(moshRoot, `linux-${arch}`, "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: `resources/mosh/linux-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
if (platform === "win32") {
// Windows normally ships the pinned runtime bundle. Keep DLL/terminfo
// packaging optional so the standalone fallback remains packageable.
const arch = requestedArch();
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
const dllDir = path.join(moshRoot, `win32-${arch}`, `mosh-client-win32-${arch}-dlls`);
if (!hasFile(exe)) return [];
const resources = [
return [
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
];
if (hasDir(dllDir)) {
resources.push({
from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`,
to: `mosh/mosh-client-win32-${arch}-dlls/`,
filter: ["**/*"],
});
}
const terminfoDir = path.join(moshRoot, `win32-${arch}`, "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: `resources/mosh/win32-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
return [];
+4 -51
View File
@@ -32,10 +32,11 @@ function writeFile(filePath) {
fs.writeFileSync(filePath, "x");
}
test("moshExtraResources returns concrete Linux arch paths (legacy bundle without terminfo)", (t) => {
test("moshExtraResources packages pure Linux client only", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "linux-x64", "mosh-client"));
writeFile(path.join(root, "resources", "mosh", "linux-x64", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("linux");
assert.deepEqual(got, [
@@ -43,20 +44,7 @@ test("moshExtraResources returns concrete Linux arch paths (legacy bundle withou
]);
});
test("moshExtraResources packages bundled terminfo on Linux when present", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "arm64");
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "mosh-client"));
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("linux");
assert.deepEqual(got, [
{ from: "resources/mosh/linux-arm64/", to: "mosh/", filter: ["mosh-client"] },
{ from: "resources/mosh/linux-arm64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
});
test("moshExtraResources packages bundled terminfo on Darwin when present", (t) => {
test("moshExtraResources packages pure Darwin client only", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "mosh-client"));
@@ -65,11 +53,10 @@ test("moshExtraResources packages bundled terminfo on Darwin when present", (t)
const got = moshExtraResources("darwin");
assert.deepEqual(got, [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
{ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
});
test("moshExtraResources returns concrete Windows arch paths only when that arch exists", (t) => {
test("moshExtraResources packages pure Windows client only (ignores dlls/terminfo)", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
@@ -79,42 +66,8 @@ test("moshExtraResources returns concrete Windows arch paths only when that arch
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
{
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
to: "mosh/mosh-client-win32-x64-dlls/",
filter: ["**/*"],
},
{ from: "resources/mosh/win32-x64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
process.env.npm_config_arch = "arm64";
assert.deepEqual(moshExtraResources("win32"), []);
});
test("moshExtraResources keeps legacy Windows bundles packageable", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
{
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
to: "mosh/mosh-client-win32-x64-dlls/",
filter: ["**/*"],
},
]);
});
test("moshExtraResources packages standalone Windows mosh-client.exe", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
]);
});
+70 -14
View File
@@ -1,38 +1,88 @@
#!/usr/bin/env node
/* eslint-disable no-console */
//
// Resolve the mosh-client binary release used by build-packages.
// Resolve the MoshCatty mosh-client binary release used by packaging / dev.
//
// Priority:
// 1. MOSH_BIN_RELEASE from workflow input / repository variable.
// 2. Latest non-draft, non-prerelease GitHub Release whose tag is
// mosh-bin-* in MOSH_BIN_OWNER/MOSH_BIN_REPO. By default this is a
// dedicated sibling binary repository named Netcatty-mosh-bin.
// moshcatty-* in MOSH_BIN_OWNER/MOSH_BIN_REPO (default binaricat/MoshCatty).
//
// In GitHub Actions, the resolved tag is written back to $GITHUB_ENV so
// later steps can run scripts/fetch-mosh-binaries.cjs without duplicating
// release discovery logic.
// In GitHub Actions, the resolved tag is written to $GITHUB_ENV.
const fs = require("node:fs");
const https = require("node:https");
const TAG_RE = /^mosh-bin-[A-Za-z0-9._-]+$/;
// MoshCatty pure-Rust releases only.
// Minimum 0.1.2: earlier Linux builds linked GLIBC 2.34 (above Netcatty floors).
// Allow semver prerelease (-rc1) and build metadata (+meta); no path separators.
const TAG_RE = /^moshcatty-[A-Za-z0-9._+-]+$/;
const MIN_VERSION = { major: 0, minor: 1, patch: 2 };
const MIN_TAG = `moshcatty-${MIN_VERSION.major}.${MIN_VERSION.minor}.${MIN_VERSION.patch}`;
function log(msg) {
console.log(`[resolve-mosh-bin-release] ${msg}`);
}
/**
* Parse moshcatty-X.Y.Z with optional prerelease (-rc1) and build (+meta).
* Returns null if not semver-ish.
*/
function parseMoshCattyVersion(tag) {
const match = String(tag || "").trim().match(
/^moshcatty-(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
);
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
// Present when tag is e.g. moshcatty-0.1.2-rc1 (semver: prerelease < final).
prerelease: match[4] || null,
};
}
function compareCoreVersion(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
/**
* True when tag is usable for packaging (core version ≥ min, with semver
* prerelease rules: X.Y.Z-rcN is below final X.Y.Z).
*/
function isAtLeastMinRelease(tag) {
const version = parseMoshCattyVersion(tag);
if (!version) return false;
const core = compareCoreVersion(version, MIN_VERSION);
if (core > 0) return true;
if (core < 0) return false;
// Equal to the floor: final only (no prerelease suffix).
return !version.prerelease;
}
function validateReleaseTag(tag) {
const value = String(tag || "").trim();
if (!TAG_RE.test(value)) {
throw new Error(`invalid mosh binary release tag: ${tag}`);
if (!TAG_RE.test(value) || !parseMoshCattyVersion(value)) {
throw new Error(`invalid mosh binary release tag: ${tag} (expected moshcatty-X.Y.Z[(-pre)|(+build)])`);
}
if (!isAtLeastMinRelease(value)) {
throw new Error(
`mosh binary release ${value} is below minimum ${MIN_TAG} `
+ "(Linux glibc floors: x64 ≤ 2.28, arm64 ≤ 2.31; 0.1.0/0.1.1 require GLIBC 2.34; "
+ "prereleases of the floor e.g. 0.1.2-rc1 are not accepted)",
);
}
return value;
}
function parseRepository(env) {
const owner = env.MOSH_BIN_OWNER || (env.GITHUB_REPOSITORY || "").split("/")[0] || "binaricat";
const repo = env.MOSH_BIN_REPO || "Netcatty-mosh-bin";
// Canonical default is always binaricat/MoshCatty. Do not derive owner from
// GITHUB_REPOSITORY — fork packaging would otherwise look for
// <fork-owner>/MoshCatty and fail. Override only via MOSH_BIN_OWNER/REPO.
const owner = env.MOSH_BIN_OWNER || "binaricat";
const repo = env.MOSH_BIN_REPO || "MoshCatty";
return { owner, repo };
}
@@ -46,8 +96,10 @@ function pickLatestMoshBinRelease(releases) {
return releases
.map((release, index) => ({ release, index }))
.filter(({ release }) => {
const tag = String(release?.tag_name || "");
return release
&& TAG_RE.test(String(release.tag_name || ""))
&& TAG_RE.test(tag)
&& isAtLeastMinRelease(tag)
&& release.draft !== true
&& release.prerelease !== true;
})
@@ -122,7 +174,7 @@ async function loadReleases(env, request = requestJsonWithHeaders) {
const { owner, repo } = parseRepository(env);
const apiBase = (env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "");
let url = `${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases?per_page=100`;
log(`looking up latest mosh-bin-* release in ${owner}/${repo}`);
log(`looking up latest moshcatty-* release in ${owner}/${repo}`);
const releases = [];
const seen = new Set();
while (url) {
@@ -159,7 +211,8 @@ async function main(env = process.env) {
const release = pickLatestMoshBinRelease(releases);
if (!release) {
throw new Error(
"could not find a non-draft mosh-bin-* release in the mosh binary repository. Publish build-mosh-binaries artifacts with release_tag (for example mosh-bin-1.4.0-1) before packaging.",
`could not find a non-draft ${MIN_TAG}+ release in binaricat/MoshCatty. `
+ `Publish a MoshCatty GitHub Release (e.g. ${MIN_TAG}) before packaging.`,
);
}
@@ -180,7 +233,10 @@ module.exports = {
loadReleases,
parseNextLink,
validateReleaseTag,
parseMoshCattyVersion,
isAtLeastMinRelease,
parseRepository,
pickLatestMoshBinRelease,
MIN_TAG,
main,
};
+59 -25
View File
@@ -11,6 +11,8 @@ const {
parseNextLink,
pickLatestMoshBinRelease,
validateReleaseTag,
isAtLeastMinRelease,
MIN_TAG,
} = require("./resolve-mosh-bin-release.cjs");
function makeTmp(t) {
@@ -19,17 +21,37 @@ function makeTmp(t) {
return dir;
}
test("validateReleaseTag accepts only mosh binary release tags", () => {
assert.equal(validateReleaseTag("mosh-bin-1.4.0-1"), "mosh-bin-1.4.0-1");
test("validateReleaseTag accepts only moshcatty-* tags at min version", () => {
assert.equal(validateReleaseTag("moshcatty-0.1.2"), "moshcatty-0.1.2");
assert.equal(validateReleaseTag("moshcatty-0.2.0"), "moshcatty-0.2.0");
assert.equal(validateReleaseTag("moshcatty-0.1.3-rc1"), "moshcatty-0.1.3-rc1");
assert.equal(validateReleaseTag("moshcatty-0.1.2+build.1"), "moshcatty-0.1.2+build.1");
assert.throws(() => validateReleaseTag("mosh-bin-1.4.0-1"), /invalid mosh binary release tag/);
assert.throws(() => validateReleaseTag("v1.2.3"), /invalid mosh binary release tag/);
assert.throws(() => validateReleaseTag("mosh-bin-../bad"), /invalid mosh binary release tag/);
assert.throws(() => validateReleaseTag("moshcatty-../bad"), /invalid mosh binary release tag/);
assert.throws(() => validateReleaseTag("moshcatty-not-a-version"), /invalid mosh binary release tag/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.0"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.1"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.2-rc1"), /below minimum/);
});
test("parseRepository falls back to the dedicated mosh binary repository", () => {
assert.deepEqual(parseRepository({}), { owner: "binaricat", repo: "Netcatty-mosh-bin" });
test("isAtLeastMinRelease enforces moshcatty-0.1.2 floor with semver prerelease rules", () => {
assert.equal(MIN_TAG, "moshcatty-0.1.2");
assert.equal(isAtLeastMinRelease("moshcatty-0.1.1"), false);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.2"), true);
// Prerelease of the floor sorts below the final floor release.
assert.equal(isAtLeastMinRelease("moshcatty-0.1.2-rc1"), false);
// Above the floor, prereleases are fine.
assert.equal(isAtLeastMinRelease("moshcatty-0.1.3-rc1"), true);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.2+build.1"), true);
assert.equal(isAtLeastMinRelease("moshcatty-not-a-version"), false);
});
test("parseRepository defaults to binaricat/MoshCatty (ignores GITHUB_REPOSITORY fork owner)", () => {
assert.deepEqual(parseRepository({}), { owner: "binaricat", repo: "MoshCatty" });
assert.deepEqual(parseRepository({ GITHUB_REPOSITORY: "owner/project" }), {
owner: "owner",
repo: "Netcatty-mosh-bin",
owner: "binaricat",
repo: "MoshCatty",
});
assert.deepEqual(
parseRepository({ GITHUB_REPOSITORY: "owner/project", MOSH_BIN_OWNER: "bin", MOSH_BIN_REPO: "binaries" }),
@@ -37,16 +59,17 @@ test("parseRepository falls back to the dedicated mosh binary repository", () =>
);
});
test("pickLatestMoshBinRelease ignores non-packaging releases", () => {
test("pickLatestMoshBinRelease ignores non-moshcatty and pre-0.1.2 tags", () => {
const got = pickLatestMoshBinRelease([
{ tag_name: "v1.0.0", published_at: "2026-03-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-3", draft: true, published_at: "2026-04-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-4", prerelease: true, published_at: "2026-04-02T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-1", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-2", published_at: "2026-03-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-2", published_at: "2026-06-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", draft: true, published_at: "2026-07-11T00:00:00Z" },
{ tag_name: "moshcatty-0.1.0", published_at: "2026-05-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.1", published_at: "2026-07-10T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", published_at: "2026-07-10T12:00:00Z" },
]);
assert.equal(got, "mosh-bin-1.4.0-2");
assert.equal(got, "moshcatty-0.1.2");
});
test("parseNextLink reads the next GitHub pagination URL", () => {
@@ -69,7 +92,7 @@ test("loadReleases follows GitHub pagination until the last page", async () => {
requested.push(url);
if (url.includes("page=2")) {
return {
json: [{ tag_name: "mosh-bin-1.4.0-1", published_at: "2026-01-01T00:00:00Z" }],
json: [{ tag_name: "moshcatty-0.1.2", published_at: "2026-01-01T00:00:00Z" }],
headers: {},
};
}
@@ -81,7 +104,7 @@ test("loadReleases follows GitHub pagination until the last page", async () => {
};
});
assert.deepEqual(got.map((release) => release.tag_name), ["v1.0.0", "mosh-bin-1.4.0-1"]);
assert.deepEqual(got.map((release) => release.tag_name), ["v1.0.0", "moshcatty-0.1.2"]);
assert.equal(requested.length, 2);
});
@@ -99,34 +122,45 @@ test("main keeps an explicit MOSH_BIN_RELEASE and exports it", async (t) => {
const githubEnv = path.join(makeTmp(t), "github-env");
const got = await main({
MOSH_BIN_RELEASE: "mosh-bin-1.4.0-1",
MOSH_BIN_RELEASE: "moshcatty-0.1.2",
GITHUB_ENV: githubEnv,
});
assert.equal(got, "mosh-bin-1.4.0-1");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=mosh-bin-1.4.0-1\n");
assert.equal(got, "moshcatty-0.1.2");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=moshcatty-0.1.2\n");
});
test("main resolves the latest release from the release list and exports it", async (t) => {
test("main rejects explicit pre-0.1.2 MOSH_BIN_RELEASE", async () => {
await assert.rejects(
main({ MOSH_BIN_RELEASE: "moshcatty-0.1.1" }),
/below minimum/,
);
});
test("main resolves the latest moshcatty release from the list and exports it", async (t) => {
const githubEnv = path.join(makeTmp(t), "github-env");
const got = await main({
GITHUB_ENV: githubEnv,
MOSH_BIN_RELEASES_JSON: JSON.stringify([
{ tag_name: "mosh-bin-1.4.0-1", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-2", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.0", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.1", published_at: "2026-07-10T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", published_at: "2026-07-10T12:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-2", published_at: "2026-08-01T00:00:00Z" },
]),
});
assert.equal(got, "mosh-bin-1.4.0-2");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=mosh-bin-1.4.0-2\n");
assert.equal(got, "moshcatty-0.1.2");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=moshcatty-0.1.2\n");
});
test("main fails when no usable release exists", async () => {
test("main fails when no usable moshcatty release exists", async () => {
await assert.rejects(
main({
MOSH_BIN_RELEASES_JSON: JSON.stringify([
{ tag_name: "v1.0.0", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-1", draft: true, published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-1", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.1", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", draft: true, published_at: "2026-02-01T00:00:00Z" },
]),
}),
/could not find/,