diff --git a/.github/workflows/build-mosh-binaries.yml b/.github/workflows/build-mosh-binaries.yml deleted file mode 100644 index 482c31de0..000000000 --- a/.github/workflows/build-mosh-binaries.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 778c4dcea..0cd1bd132 100755 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/ET_INTEGRATION_CHECKLIST.md b/ET_INTEGRATION_CHECKLIST.md index 3ae675fd6..3a8bcda34 100644 --- a/ET_INTEGRATION_CHECKLIST.md +++ b/ET_INTEGRATION_CHECKLIST.md @@ -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//mosh-client[.exe]` | `resources/et//et[.exe]` | | 打包后 `/mosh/mosh-client` | 打包后 `/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-.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//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//`。**Windows 用自建产物** - (ET 官方有 Windows 构建,无需 FluentTerminal 那种 fallback)。去掉 terminfo 校验。 + SHA256SUMS 校验;解包到 `resources/et//`。**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`、 diff --git a/docs/designs/native-mosh-client.md b/docs/designs/native-mosh-client.md new file mode 100644 index 000000000..330be2d12 --- /dev/null +++ b/docs/designs/native-mosh-client.md @@ -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= mosh-client +``` + +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). diff --git a/electron/bridges/terminalBridge.bareMoshClient.test.cjs b/electron/bridges/terminalBridge.bareMoshClient.test.cjs index 490d0909d..148247a75 100644 --- a/electron/bridges/terminalBridge.bareMoshClient.test.cjs +++ b/electron/bridges/terminalBridge.bareMoshClient.test.cjs @@ -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//.... 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); +}); diff --git a/electron/bridges/terminalBridge.cjs b/electron/bridges/terminalBridge.cjs index 502a61eb7..44c6459ba 100644 --- a/electron/bridges/terminalBridge.cjs +++ b/electron/bridges/terminalBridge.cjs @@ -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, diff --git a/electron/bridges/terminalBridge/moshSession.cjs b/electron/bridges/terminalBridge/moshSession.cjs index fe1511f40..e5a179003 100644 --- a/electron/bridges/terminalBridge/moshSession.cjs +++ b/electron/bridges/terminalBridge/moshSession.cjs @@ -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, diff --git a/resources/et/README.md b/resources/et/README.md index 54e9f49f1..71d324ac3 100644 --- a/resources/et/README.md +++ b/resources/et/README.md @@ -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. diff --git a/resources/mosh/README.md b/resources/mosh/README.md index 56e138e07..7b8aa4518 100644 --- a/resources/mosh/README.md +++ b/resources/mosh/README.md @@ -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//`. 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** diff --git a/scripts/build-mosh/build-linux.sh b/scripts/build-mosh/build-linux.sh deleted file mode 100755 index 77329a479..000000000 --- a/scripts/build-mosh/build-linux.sh +++ /dev/null @@ -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-.tar.gz + sha256 -# -# Output: -# $OUT_DIR/mosh-client-linux-.tar.gz (binary + terminfo bundle) -# $OUT_DIR/mosh-client-linux-.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" diff --git a/scripts/build-mosh/build-macos.sh b/scripts/build-mosh/build-macos.sh deleted file mode 100755 index 7f11e4d29..000000000 --- a/scripts/build-mosh/build-macos.sh +++ /dev/null @@ -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" diff --git a/scripts/build-mosh/fetch-windows.sh b/scripts/build-mosh/fetch-windows.sh deleted file mode 100755 index 1de64902a..000000000 --- a/scripts/build-mosh/fetch-windows.sh +++ /dev/null @@ -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" diff --git a/scripts/fetch-mosh-binaries.cjs b/scripts/fetch-mosh-binaries.cjs index 637e6f286..66c952fa1 100755 --- a/scripts/fetch-mosh-binaries.cjs +++ b/scripts/fetch-mosh-binaries.cjs @@ -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, }; diff --git a/scripts/fetch-mosh-binaries.test.cjs b/scripts/fetch-mosh-binaries.test.cjs index e87ce570e..f8b18ec8b 100644 --- a/scripts/fetch-mosh-binaries.test.cjs +++ b/scripts/fetch-mosh-binaries.test.cjs @@ -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/, ); }); diff --git a/scripts/github-workflow-build.test.cjs b/scripts/github-workflow-build.test.cjs index 3c9a80cd6..d95495f9d 100644 --- a/scripts/github-workflow-build.test.cjs +++ b/scripts/github-workflow-build.test.cjs @@ -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], diff --git a/scripts/mosh-extra-resources.cjs b/scripts/mosh-extra-resources.cjs index 82506ce6b..4fd6ceda9 100644 --- a/scripts/mosh-extra-resources.cjs +++ b/scripts/mosh-extra-resources.cjs @@ -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// 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 []; diff --git a/scripts/mosh-extra-resources.test.cjs b/scripts/mosh-extra-resources.test.cjs index b05248428..c0cc123e7 100644 --- a/scripts/mosh-extra-resources.test.cjs +++ b/scripts/mosh-extra-resources.test.cjs @@ -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"] }, - ]); -}); diff --git a/scripts/resolve-mosh-bin-release.cjs b/scripts/resolve-mosh-bin-release.cjs index 309ba8d14..f85b46b21 100644 --- a/scripts/resolve-mosh-bin-release.cjs +++ b/scripts/resolve-mosh-bin-release.cjs @@ -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 + // /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, }; diff --git a/scripts/resolve-mosh-bin-release.test.cjs b/scripts/resolve-mosh-bin-release.test.cjs index 43c03f2e1..56abc0999 100644 --- a/scripts/resolve-mosh-bin-release.test.cjs +++ b/scripts/resolve-mosh-bin-release.test.cjs @@ -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/,