Files
NetMesh/scripts/resolve-mosh-bin-release.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

247 lines
7.9 KiB
JavaScript

#!/usr/bin/env node
/* eslint-disable no-console */
//
// 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
// moshcatty-* in MOSH_BIN_OWNER/MOSH_BIN_REPO (default binaricat/MoshCatty).
//
// In GitHub Actions, the resolved tag is written to $GITHUB_ENV.
const fs = require("node:fs");
const https = require("node:https");
// MoshCatty pure-Rust releases only.
// Minimum 0.1.8: disable local backspace prediction until the host confirms
// the resulting screen, preventing stale cursor/character display on latency.
// 0.1.7 reconstructed numbered remote states before display; 0.1.6 added
// prediction hardening; 0.1.5 introduced the Diff path.
// 0.1.4 ConPTY shortcut; 0.1.2+ Linux glibc floors match Netcatty.
// 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: 8 };
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.4-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) || !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} `
+ "(0.1.8 disables unsafe local backspace prediction; "
+ "prereleases of the floor e.g. 0.1.8-rc1 are not accepted)",
);
}
return value;
}
function parseRepository(env) {
// Canonical default is always binaricat/MoshCatty. Do not derive owner from
// GITHUB_REPOSITORY — fork packaging would otherwise look for
// <fork-owner>/MoshCatty and fail. Override only via MOSH_BIN_OWNER/REPO.
const owner = env.MOSH_BIN_OWNER || "binaricat";
const repo = env.MOSH_BIN_REPO || "MoshCatty";
return { owner, repo };
}
function releaseTimestamp(release) {
const raw = release.published_at || release.created_at || "";
const value = Date.parse(raw);
return Number.isNaN(value) ? 0 : value;
}
function pickLatestMoshBinRelease(releases) {
return releases
.map((release, index) => ({ release, index }))
.filter(({ release }) => {
const tag = String(release?.tag_name || "");
return release
&& TAG_RE.test(tag)
&& isAtLeastMinRelease(tag)
&& release.draft !== true
&& release.prerelease !== true;
})
.sort((a, b) => {
const diff = releaseTimestamp(b.release) - releaseTimestamp(a.release);
return diff || a.index - b.index;
})[0]?.release.tag_name;
}
function parseNextLink(linkHeader) {
if (!linkHeader) return null;
for (const part of String(linkHeader).split(",")) {
const match = part.match(/^\s*<([^>]+)>\s*;\s*(.+)\s*$/);
if (!match) continue;
const rel = match[2].split(";").some((attr) => attr.trim() === 'rel="next"');
if (rel) return match[1];
}
return null;
}
function requestJsonWithHeaders(url, env, depth = 0) {
return new Promise((resolve, reject) => {
if (depth > 5) {
reject(new Error("too many redirects while looking up mosh binary releases"));
return;
}
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "netcatty-mosh-release-resolver",
"X-GitHub-Api-Version": "2022-11-28",
};
const token = env.GITHUB_TOKEN || env.GH_TOKEN;
if (token) headers.Authorization = `Bearer ${token}`;
https.get(url, { headers }, (res) => {
const location = res.headers.location;
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
res.resume();
resolve(requestJsonWithHeaders(new URL(location, url).toString(), env, depth + 1));
return;
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if (res.statusCode !== 200) {
reject(new Error(`GitHub API returned HTTP ${res.statusCode}: ${body.slice(0, 300)}`));
return;
}
try {
resolve({ json: JSON.parse(body), headers: res.headers });
} catch (err) {
reject(new Error(`GitHub API returned invalid JSON: ${err.message}`));
}
});
res.on("error", reject);
}).on("error", reject);
});
}
async function loadReleases(env, request = requestJsonWithHeaders) {
if (env.MOSH_BIN_RELEASES_JSON) {
const parsed = JSON.parse(env.MOSH_BIN_RELEASES_JSON);
if (!Array.isArray(parsed)) {
throw new Error("MOSH_BIN_RELEASES_JSON must be a JSON array");
}
return parsed;
}
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 moshcatty-* release in ${owner}/${repo}`);
const releases = [];
const seen = new Set();
while (url) {
if (seen.has(url)) {
throw new Error(`GitHub API pagination looped while looking up releases: ${url}`);
}
seen.add(url);
const { json, headers = {} } = await request(url, env);
if (!Array.isArray(json)) {
throw new Error("GitHub API releases response was not an array");
}
releases.push(...json);
url = parseNextLink(headers.link);
}
return releases;
}
function exportRelease(release, env) {
if (env.GITHUB_ENV) {
fs.appendFileSync(env.GITHUB_ENV, `MOSH_BIN_RELEASE=${release}\n`, "utf8");
}
}
async function main(env = process.env) {
if (String(env.MOSH_BIN_RELEASE || "").trim()) {
const release = validateReleaseTag(env.MOSH_BIN_RELEASE);
exportRelease(release, env);
log(`using MOSH_BIN_RELEASE=${release}`);
return release;
}
const releases = await loadReleases(env);
const release = pickLatestMoshBinRelease(releases);
if (!release) {
throw new Error(
`could not find a non-draft ${MIN_TAG}+ release in binaricat/MoshCatty. `
+ `Publish a MoshCatty GitHub Release (e.g. ${MIN_TAG}) before packaging.`,
);
}
const validated = validateReleaseTag(release);
exportRelease(validated, env);
log(`resolved MOSH_BIN_RELEASE=${validated}`);
return validated;
}
if (require.main === module) {
main().catch((err) => {
console.error(`[resolve-mosh-bin-release] FATAL ${err.message}`);
process.exit(1);
});
}
module.exports = {
loadReleases,
parseNextLink,
validateReleaseTag,
parseMoshCattyVersion,
isAtLeastMinRelease,
parseRepository,
pickLatestMoshBinRelease,
MIN_TAG,
main,
};