[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,367 @@
/**
* electron-builder afterPack hook — give the macOS app a unique Mach-O LC_UUID.
*
* macOS keys the "Local Network" privacy permission on the main executable's
* Mach-O LC_UUID (see Apple TN3179). Electron's prebuilt binary is linked with
* LLD, which derives the UUID from a content hash, so EVERY app built from the
* same Electron version ships the *same* LC_UUID — even with a different bundle
* id. That collision makes the Local Network grant unreliable: macOS may apply
* another Electron app's decision to ours, so a user who toggles the permission
* on still gets `EHOSTUNREACH` when connecting to LAN/VMware host-only addresses
* (issue #1040).
*
* This hook rewrites the LC_UUID of the packaged main executable to a value
* derived deterministically from the appId — stable across builds (so users
* don't have to re-grant on every update) but distinct from every other app.
* It runs in `afterPack`, i.e. BEFORE electron-builder code-signs, so the
* signature/notarization covers the patched binary.
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { execFileSync } = require("node:child_process");
const {
copyPatchedNodePtyToPackagedApp,
rebuildPatchedNodePty,
} = require("./nodePtyConptyPatch.cjs");
const LC_UUID = 0x1b;
const MH_MAGIC_64 = 0xfeedfacf; // thin 64-bit, little-endian on disk
const MH_CIGAM_64 = 0xcffaedfe; // thin 64-bit, byte-swapped
const FAT_MAGIC = 0xcafebabe; // fat, big-endian
const FAT_MAGIC_64 = 0xcafebabf;
const MACH_HEADER_64_SIZE = 32;
/**
* Deterministic, app-specific 16-byte UUID. Stable across builds (so the
* Local Network grant survives updates) yet unique per appId.
* @param {string} appId
* @returns {Buffer}
*/
function deriveUuid(appId) {
const hash = crypto.createHash("sha1").update(`netcatty-local-network|${appId}`).digest();
const uuid = Buffer.from(hash.subarray(0, 16));
uuid[6] = (uuid[6] & 0x0f) | 0x50; // version 5
uuid[8] = (uuid[8] & 0x3f) | 0x80; // RFC 4122 variant
return uuid;
}
function formatUuid(buf) {
const h = buf.toString("hex").toUpperCase();
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
/**
* Patch every LC_UUID load command inside a single thin Mach-O slice.
* @returns {string[]} the old UUIDs that were replaced (hex)
*/
function patchThinSlice(buf, sliceOffset, uuid) {
const magic = buf.readUInt32LE(sliceOffset);
if (magic !== MH_MAGIC_64 && magic !== MH_CIGAM_64) return [];
const swapped = magic === MH_CIGAM_64;
const readU32 = (o) => (swapped ? buf.readUInt32BE(o) : buf.readUInt32LE(o));
const ncmds = readU32(sliceOffset + 16);
let off = sliceOffset + MACH_HEADER_64_SIZE;
const replaced = [];
for (let i = 0; i < ncmds; i += 1) {
const cmd = readU32(off);
const cmdsize = readU32(off + 4);
if (cmdsize <= 0) break;
if (cmd === LC_UUID) {
replaced.push(buf.subarray(off + 8, off + 24).toString("hex"));
uuid.copy(buf, off + 8); // uuid[16] follows cmd(4) + cmdsize(4)
}
off += cmdsize;
}
return replaced;
}
/**
* Rewrite all LC_UUID load commands in a Mach-O buffer (thin or fat) in place.
* @returns {{ patched: number, oldUuids: string[] }}
*/
function patchMachOBuffer(buf, uuid) {
const magicBE = buf.readUInt32BE(0);
const oldUuids = [];
if (magicBE === FAT_MAGIC || magicBE === FAT_MAGIC_64) {
const is64 = magicBE === FAT_MAGIC_64;
const archSize = is64 ? 32 : 20;
const nfat = buf.readUInt32BE(4);
for (let i = 0; i < nfat; i += 1) {
const archOff = 8 + i * archSize;
const sliceOffset = is64
? Number(buf.readBigUInt64BE(archOff + 8))
: buf.readUInt32BE(archOff + 8);
oldUuids.push(...patchThinSlice(buf, sliceOffset, uuid));
}
} else {
oldUuids.push(...patchThinSlice(buf, 0, uuid));
}
return { patched: oldUuids.length, oldUuids };
}
function patchMachOFile(file, uuid) {
const buf = fs.readFileSync(file);
const result = patchMachOBuffer(buf, uuid);
if (result.patched > 0) fs.writeFileSync(file, buf);
return result;
}
function adHocSignAppBundle(appPath, options = {}) {
const hostPlatform = options.hostPlatform || process.platform;
const execFile = options.execFileSync || execFileSync;
if (hostPlatform !== "darwin") {
console.warn(
`[afterPack] Skipping ad-hoc codesign for ${appPath}; host platform is ${hostPlatform}`,
);
return false;
}
execFile("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", appPath], {
stdio: ["ignore", "pipe", "pipe"],
});
return true;
}
const ELECTRON_BUILDER_ARCH_NAMES = {
0: "ia32",
1: "x64",
2: "armv7l",
3: "arm64",
4: "universal",
};
function archNameFromContext(context) {
const arch = context?.arch;
if (typeof arch === "string") return arch;
if (typeof arch === "number" && ELECTRON_BUILDER_ARCH_NAMES[arch]) {
return ELECTRON_BUILDER_ARCH_NAMES[arch];
}
return process.arch;
}
function cursorPlatformPackageBases(platform) {
if (platform === "darwin") return ["sdk-darwin-arm64", "sdk-darwin-x64"];
if (platform === "linux") return ["sdk-linux-arm64", "sdk-linux-x64"];
if (platform === "win32") return ["sdk-win32-x64"];
return [];
}
function cursorPackagesToKeep(platform, archName) {
if (platform === "darwin" && archName === "universal") {
return new Set(["sdk-darwin-arm64", "sdk-darwin-x64"]);
}
if (platform === "darwin" && (archName === "arm64" || archName === "x64")) {
return new Set([`sdk-darwin-${archName}`]);
}
if (platform === "linux" && (archName === "arm64" || archName === "x64")) {
return new Set([`sdk-linux-${archName}`]);
}
if (platform === "win32" && archName === "x64") {
return new Set(["sdk-win32-x64"]);
}
return new Set();
}
function appResourcesDir(context) {
if (context.electronPlatformName === "darwin") {
const productFilename = context.packager.appInfo.productFilename;
return path.join(context.appOutDir, `${productFilename}.app`, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function readAsarHeader(asarPath) {
const fd = fs.openSync(asarPath, "r");
try {
const sizeBuf = Buffer.alloc(8);
if (fs.readSync(fd, sizeBuf, 0, sizeBuf.length, 0) !== sizeBuf.length) {
throw new Error(`[afterPack] Unable to read ASAR header size: ${asarPath}`);
}
const sizePicklePayloadSize = sizeBuf.readUInt32LE(0);
if (sizePicklePayloadSize !== 4) {
throw new Error(`[afterPack] Unsupported ASAR size pickle in ${asarPath}`);
}
const headerSize = sizeBuf.readUInt32LE(4);
const headerBuf = Buffer.alloc(headerSize);
if (fs.readSync(fd, headerBuf, 0, headerSize, 8) !== headerSize) {
throw new Error(`[afterPack] Unable to read ASAR header: ${asarPath}`);
}
const headerPicklePayloadSize = headerBuf.readUInt32LE(0);
if (headerPicklePayloadSize !== headerSize - 4) {
throw new Error(`[afterPack] Unsupported ASAR header pickle in ${asarPath}`);
}
const headerStringLength = headerBuf.readInt32LE(4);
const headerString = headerBuf.subarray(8, 8 + headerStringLength).toString("utf8");
return { header: JSON.parse(headerString), headerSize };
} finally {
fs.closeSync(fd);
}
}
function writeAsarHeaderPreservingDataOffset(asarPath, header, headerSize) {
const headerString = JSON.stringify(header);
const headerStringLength = Buffer.byteLength(headerString);
const fixedPrefixSize = 8; // payload size uint32 + string length int32
if (fixedPrefixSize + headerStringLength > headerSize) {
throw new Error(
`[afterPack] Updated ASAR header is larger than the original header for ${asarPath}`,
);
}
const headerBuf = Buffer.alloc(headerSize);
headerBuf.writeUInt32LE(headerSize - 4, 0);
headerBuf.writeInt32LE(headerStringLength, 4);
headerBuf.write(headerString, fixedPrefixSize, headerStringLength, "utf8");
const fd = fs.openSync(asarPath, "r+");
try {
fs.writeSync(fd, headerBuf, 0, headerBuf.length, 8);
} finally {
fs.closeSync(fd);
}
}
function removeAsarHeaderEntry(header, entryPath) {
const segments = entryPath.split(/[\\/]+/).filter(Boolean);
if (segments.length === 0) return false;
let node = header;
for (const segment of segments.slice(0, -1)) {
node = node.files?.[segment];
if (!node) return false;
}
const leaf = segments[segments.length - 1];
if (!Object.prototype.hasOwnProperty.call(node.files || {}, leaf)) return false;
delete node.files[leaf];
return true;
}
function pruneAsarHeaderEntries(asarPath, entryPaths) {
if (!fs.existsSync(asarPath) || entryPaths.length === 0) return [];
const { header, headerSize } = readAsarHeader(asarPath);
const removed = entryPaths.filter((entryPath) => removeAsarHeaderEntry(header, entryPath));
if (removed.length > 0) {
writeAsarHeaderPreservingDataOffset(asarPath, header, headerSize);
}
return removed;
}
function pruneCursorSdkPlatformPackages(context) {
const platform = context.electronPlatformName;
const candidates = cursorPlatformPackageBases(platform);
if (candidates.length === 0) return [];
const keep = cursorPackagesToKeep(platform, archNameFromContext(context));
if (keep.size === 0) return [];
const cursorRoot = path.join(
appResourcesDir(context),
"app.asar.unpacked",
"node_modules",
"@cursor",
);
if (!fs.existsSync(cursorRoot)) return [];
const removed = [];
const asarHeaderEntriesToRemove = [];
for (const baseName of candidates) {
if (keep.has(baseName)) continue;
const dir = path.join(cursorRoot, baseName);
if (!fs.existsSync(dir)) continue;
removed.push(baseName);
asarHeaderEntriesToRemove.push(`node_modules/@cursor/${baseName}`);
}
if (removed.length === 0) return [];
const appAsar = path.join(appResourcesDir(context), "app.asar");
pruneAsarHeaderEntries(appAsar, asarHeaderEntriesToRemove);
for (const baseName of removed) {
fs.rmSync(path.join(cursorRoot, baseName), { recursive: true, force: true });
}
return removed;
}
/** @param {import('electron-builder').AfterPackContext} context */
async function afterPack(context) {
const removedCursorPackages = pruneCursorSdkPlatformPackages(context);
if (removedCursorPackages.length > 0) {
console.log(
`[afterPack] Removed unused Cursor SDK platform package(s): ${removedCursorPackages.join(", ")}`,
);
}
if (context.electronPlatformName === "win32") {
const projectDir = context.packager.appDir || context.packager.projectDir;
rebuildPatchedNodePty({ projectDir, platform: "win32", arch: context.arch });
copyPatchedNodePtyToPackagedApp({
projectDir,
resourcesDir: appResourcesDir(context),
});
console.log("[afterPack] Installed patched node-pty ConPTY runtime into packaged app");
return;
}
if (context.electronPlatformName !== "darwin") return;
const appId = context.packager.appInfo.id || "com.netcatty.app";
const productFilename = context.packager.appInfo.productFilename;
const appPath = path.join(context.appOutDir, `${productFilename}.app`);
const exePath = path.join(
appPath,
"Contents",
"MacOS",
productFilename,
);
if (!fs.existsSync(exePath)) {
throw new Error(`[afterPack] macOS executable not found: ${exePath}`);
}
const uuid = deriveUuid(appId);
const { patched, oldUuids } = patchMachOFile(exePath, uuid);
if (patched === 0) {
throw new Error(
`[afterPack] No LC_UUID load command found in ${exePath} — Local Network UUID fix did not apply`,
);
}
console.log(
`[afterPack] Mach-O LC_UUID rewritten for Local Network privacy (#1040): ` +
`${oldUuids.map((h) => formatUuid(Buffer.from(h, "hex"))).join(", ")} -> ${formatUuid(uuid)} ` +
`(${patched} slice(s), appId=${appId})`,
);
// The official Developer ID signing step runs after afterPack and replaces
// this temporary signature. Local unsigned builds skip that step, so the
// patched app bundle still needs a valid ad-hoc signature or macOS kills it
// before Electron can start. Signing the whole bundle also covers Electron's
// nested frameworks, which codesign validates as subcomponents.
if (adHocSignAppBundle(appPath)) {
console.log("[afterPack] Ad-hoc signed patched macOS app for local unsigned builds");
}
}
module.exports = afterPack;
module.exports.default = afterPack;
module.exports.deriveUuid = deriveUuid;
module.exports.formatUuid = formatUuid;
module.exports.patchMachOBuffer = patchMachOBuffer;
module.exports.patchMachOFile = patchMachOFile;
module.exports.adHocSignAppBundle = adHocSignAppBundle;
module.exports.readAsarHeader = readAsarHeader;
module.exports.pruneAsarHeaderEntries = pruneAsarHeaderEntries;
module.exports.pruneCursorSdkPlatformPackages = pruneCursorSdkPlatformPackages;

View File

@@ -0,0 +1,340 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
adHocSignAppBundle,
deriveUuid,
patchMachOBuffer,
pruneAsarHeaderEntries,
pruneCursorSdkPlatformPackages,
readAsarHeader,
} = require("./afterPackMacUuid.cjs");
const LC_UUID = 0x1b;
const LC_OTHER = 0x19;
const MH_MAGIC_64 = 0xfeedfacf;
function align4(value) {
return value + ((4 - (value % 4)) % 4);
}
function writeFakeAsar(asarPath, header, payload = Buffer.from("packed-payload")) {
const headerString = JSON.stringify(header);
const headerStringLength = Buffer.byteLength(headerString);
const headerPayloadSize = 4 + align4(headerStringLength);
const headerSize = 4 + headerPayloadSize;
const sizeBuf = Buffer.alloc(8);
const headerBuf = Buffer.alloc(headerSize);
sizeBuf.writeUInt32LE(4, 0);
sizeBuf.writeUInt32LE(headerSize, 4);
headerBuf.writeUInt32LE(headerPayloadSize, 0);
headerBuf.writeInt32LE(headerStringLength, 4);
headerBuf.write(headerString, 8, headerStringLength, "utf8");
require("node:fs").writeFileSync(asarPath, Buffer.concat([sizeBuf, headerBuf, payload]));
return headerSize;
}
// Build a minimal thin little-endian 64-bit Mach-O with two load commands:
// one dummy command and one LC_UUID carrying `uuidBytes`.
function buildThinMachO(uuidBytes) {
const header = Buffer.alloc(32);
header.writeUInt32LE(MH_MAGIC_64, 0); // magic
header.writeUInt32LE(0x0100000c, 4); // cputype arm64 (value irrelevant)
header.writeUInt32LE(0, 8); // cpusubtype
header.writeUInt32LE(2, 12); // filetype
header.writeUInt32LE(2, 16); // ncmds
header.writeUInt32LE(16 + 24, 20); // sizeofcmds
header.writeUInt32LE(0, 24); // flags
header.writeUInt32LE(0, 28); // reserved
const dummy = Buffer.alloc(16);
dummy.writeUInt32LE(LC_OTHER, 0); // cmd
dummy.writeUInt32LE(16, 4); // cmdsize
dummy.fill(0xab, 8); // payload sentinel
const uuidCmd = Buffer.alloc(24);
uuidCmd.writeUInt32LE(LC_UUID, 0); // cmd
uuidCmd.writeUInt32LE(24, 4); // cmdsize
uuidBytes.copy(uuidCmd, 8);
return Buffer.concat([header, dummy, uuidCmd]);
}
// Wrap one or more thin slices in a big-endian 32-bit fat binary.
function buildFatMachO(slices) {
const headerSize = 8 + slices.length * 20;
const header = Buffer.alloc(headerSize);
header.writeUInt32BE(0xcafebabe, 0); // FAT_MAGIC
header.writeUInt32BE(slices.length, 4);
let offset = headerSize;
const offsets = [];
for (let i = 0; i < slices.length; i += 1) {
const archOff = 8 + i * 20;
header.writeUInt32BE(0x0100000c, archOff); // cputype
header.writeUInt32BE(0, archOff + 4); // cpusubtype
header.writeUInt32BE(offset, archOff + 8); // offset
header.writeUInt32BE(slices[i].length, archOff + 12); // size
header.writeUInt32BE(0, archOff + 16); // align
offsets.push(offset);
offset += slices[i].length;
}
return Buffer.concat([header, ...slices]);
}
test("deriveUuid is deterministic and 16 bytes", () => {
const a = deriveUuid("com.netcatty.app");
const b = deriveUuid("com.netcatty.app");
assert.equal(a.length, 16);
assert.ok(a.equals(b));
});
test("deriveUuid differs per appId and sets version/variant bits", () => {
const a = deriveUuid("com.netcatty.app");
const b = deriveUuid("com.example.other");
assert.ok(!a.equals(b));
assert.equal(a[6] & 0xf0, 0x50); // version 5
assert.equal(a[8] & 0xc0, 0x80); // RFC 4122 variant
});
test("patchMachOBuffer rewrites LC_UUID in a thin Mach-O and leaves the rest intact", () => {
const original = Buffer.alloc(16, 0x11);
const buf = buildThinMachO(original);
const uuid = deriveUuid("com.netcatty.app");
const { patched, oldUuids } = patchMachOBuffer(buf, uuid);
assert.equal(patched, 1);
assert.equal(oldUuids[0], original.toString("hex"));
// LC_UUID payload is now our derived uuid (uuid command starts at byte 48).
assert.ok(buf.subarray(48 + 8, 48 + 24).equals(uuid));
// Header magic + the dummy command's payload are untouched.
assert.equal(buf.readUInt32LE(0), MH_MAGIC_64);
assert.equal(buf.readUInt32LE(32), LC_OTHER);
assert.ok(buf.subarray(32 + 8, 32 + 16).equals(Buffer.alloc(8, 0xab)));
});
test("patchMachOBuffer patches every slice of a fat binary", () => {
const slice1 = buildThinMachO(Buffer.alloc(16, 0x22));
const slice2 = buildThinMachO(Buffer.alloc(16, 0x33));
const fat = buildFatMachO([slice1, slice2]);
const uuid = deriveUuid("com.netcatty.app");
const { patched } = patchMachOBuffer(fat, uuid);
assert.equal(patched, 2);
});
test("patchMachOBuffer reports zero when there is no LC_UUID", () => {
// A thin Mach-O whose single command is not LC_UUID.
const header = Buffer.alloc(32);
header.writeUInt32LE(MH_MAGIC_64, 0);
header.writeUInt32LE(1, 16); // ncmds
const cmd = Buffer.alloc(16);
cmd.writeUInt32LE(LC_OTHER, 0);
cmd.writeUInt32LE(16, 4);
const buf = Buffer.concat([header, cmd]);
const { patched } = patchMachOBuffer(buf, deriveUuid("com.netcatty.app"));
assert.equal(patched, 0);
});
test("adHocSignAppBundle signs the full app bundle on macOS hosts", () => {
const calls = [];
const didSign = adHocSignAppBundle("/tmp/Netcatty.app", {
hostPlatform: "darwin",
execFileSync: (bin, args, options) => {
calls.push({ bin, args, options });
},
});
assert.equal(didSign, true);
assert.deepEqual(calls, [
{
bin: "codesign",
args: [
"--force",
"--deep",
"--sign",
"-",
"--timestamp=none",
"/tmp/Netcatty.app",
],
options: { stdio: ["ignore", "pipe", "pipe"] },
},
]);
});
test("adHocSignAppBundle skips non-macOS hosts", () => {
let called = false;
const didSign = adHocSignAppBundle("/tmp/Netcatty.app", {
hostPlatform: "linux",
execFileSync: () => {
called = true;
},
});
assert.equal(didSign, false);
assert.equal(called, false);
});
test("pruneAsarHeaderEntries removes package records without moving packed payload", (t) => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-asar-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const asarPath = path.join(tempDir, "app.asar");
const payload = Buffer.from("packed-payload");
const headerSize = writeFakeAsar(
asarPath,
{
files: {
node_modules: {
files: {
"@cursor": {
files: {
"sdk-darwin-arm64": {
files: {
"package.json": { size: 2, unpacked: true },
},
},
"sdk-darwin-x64": {
files: {
"package.json": { size: 2, unpacked: true },
},
},
},
},
},
},
"packed.txt": { size: payload.length, offset: "0" },
},
},
payload,
);
const removed = pruneAsarHeaderEntries(asarPath, ["node_modules/@cursor/sdk-darwin-x64"]);
const { header, headerSize: updatedHeaderSize } = readAsarHeader(asarPath);
const packedPayload = fs.readFileSync(asarPath).subarray(8 + headerSize);
assert.deepEqual(removed, ["node_modules/@cursor/sdk-darwin-x64"]);
assert.equal(updatedHeaderSize, headerSize);
assert.ok(header.files.node_modules.files["@cursor"].files["sdk-darwin-arm64"]);
assert.equal(header.files.node_modules.files["@cursor"].files["sdk-darwin-x64"], undefined);
assert.equal(packedPayload.toString("utf8"), payload.toString("utf8"));
});
test("pruneCursorSdkPlatformPackages keeps only the target macOS arch package", (t) => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const cursorRoot = path.join(
tempDir,
"Netcatty.app",
"Contents",
"Resources",
"app.asar.unpacked",
"node_modules",
"@cursor",
);
fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-arm64"), { recursive: true });
fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-x64"), { recursive: true });
writeFakeAsar(path.join(tempDir, "Netcatty.app", "Contents", "Resources", "app.asar"), {
files: {
node_modules: {
files: {
"@cursor": {
files: {
"sdk-darwin-arm64": { files: { "package.json": { size: 2, unpacked: true } } },
"sdk-darwin-x64": { files: { "package.json": { size: 2, unpacked: true } } },
},
},
},
},
},
});
const removed = pruneCursorSdkPlatformPackages({
electronPlatformName: "darwin",
arch: 3,
appOutDir: tempDir,
packager: { appInfo: { productFilename: "Netcatty" } },
});
assert.deepEqual(removed, ["sdk-darwin-x64"]);
assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-arm64")));
assert.ok(!fs.existsSync(path.join(cursorRoot, "sdk-darwin-x64")));
const { header } = readAsarHeader(
path.join(tempDir, "Netcatty.app", "Contents", "Resources", "app.asar"),
);
assert.ok(header.files.node_modules.files["@cursor"].files["sdk-darwin-arm64"]);
assert.equal(header.files.node_modules.files["@cursor"].files["sdk-darwin-x64"], undefined);
});
test("pruneCursorSdkPlatformPackages keeps both macOS packages for universal builds", (t) => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const cursorRoot = path.join(
tempDir,
"Netcatty.app",
"Contents",
"Resources",
"app.asar.unpacked",
"node_modules",
"@cursor",
);
fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-arm64"), { recursive: true });
fs.mkdirSync(path.join(cursorRoot, "sdk-darwin-x64"), { recursive: true });
const removed = pruneCursorSdkPlatformPackages({
electronPlatformName: "darwin",
arch: 4,
appOutDir: tempDir,
packager: { appInfo: { productFilename: "Netcatty" } },
});
assert.deepEqual(removed, []);
assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-arm64")));
assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-darwin-x64")));
});
test("pruneCursorSdkPlatformPackages keeps only the target Linux arch package", (t) => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-prune-cursor-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const cursorRoot = path.join(
tempDir,
"resources",
"app.asar.unpacked",
"node_modules",
"@cursor",
);
fs.mkdirSync(path.join(cursorRoot, "sdk-linux-arm64"), { recursive: true });
fs.mkdirSync(path.join(cursorRoot, "sdk-linux-x64"), { recursive: true });
const removed = pruneCursorSdkPlatformPackages({
electronPlatformName: "linux",
arch: 1,
appOutDir: tempDir,
packager: { appInfo: { productFilename: "netcatty" } },
});
assert.deepEqual(removed, ["sdk-linux-arm64"]);
assert.ok(!fs.existsSync(path.join(cursorRoot, "sdk-linux-arm64")));
assert.ok(fs.existsSync(path.join(cursorRoot, "sdk-linux-x64")));
});

4251
scripts/ai-automation.cjs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

276
scripts/ai-brave-search.cjs Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const http = require('node:http');
const https = require('node:https');
const { promises: dnsPromises } = require('node:dns');
const { URL } = require('node:url');
const { isIP } = require('node:net');
const BRAVE_SEARCH_URL = 'https://api.search.brave.com/res/v1/web/search';
const USER_AGENT = 'netcatty-ai-automation/1.0';
const MAX_FETCH_BYTES = 200_000;
const FETCH_TIMEOUT_MS = 10_000;
const SEARCH_TIMEOUT_MS = 12_000;
function readApiKey() {
const file = String(process.env.BRAVE_API_KEY_FILE || '').trim();
if (file) {
const value = fs.readFileSync(file, 'utf8').replace(/[\r\n]+$/g, '');
if (!value) throw new Error('BRAVE_API_KEY_FILE is empty.');
return value;
}
const env = String(process.env.BRAVE_API_KEY || '').trim();
if (!env) throw new Error('BRAVE_API_KEY is not configured.');
return env;
}
function appendToolLog(event) {
const logPath = String(process.env.BRAVE_TOOL_LOG || '').trim();
if (!logPath) return;
fs.appendFileSync(logPath, `${JSON.stringify(event)}\n`);
}
function requestJson(url, { headers = {}, timeoutMs = SEARCH_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const req = lib.request(parsed, {
method: 'GET',
headers,
timeout: timeoutMs,
}, (res) => {
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_FETCH_BYTES) {
req.destroy(new Error('Response exceeded byte limit.'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 300)}`));
return;
}
try {
resolve(JSON.parse(body));
} catch {
reject(new Error('Brave Search returned non-JSON.'));
}
});
});
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
req.on('error', reject);
req.end();
});
}
async function requestText(url, { timeoutMs = FETCH_TIMEOUT_MS, redirects = 0 } = {}) {
if (redirects > 2) {
throw new Error('Too many redirects.');
}
let parsed;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL.');
}
if (parsed.protocol !== 'https:') {
throw new Error('Only https URLs can be fetched.');
}
if (isPrivateHostname(parsed.hostname)) {
throw new Error('Refusing to fetch a private or local address.');
}
let addresses;
try {
addresses = await dnsPromises.lookup(parsed.hostname, { all: true, verbatim: true });
} catch {
throw new Error('Could not resolve fetch host.');
}
if (
!addresses.length
|| addresses.some((entry) => isPrivateHostname(entry.address))
) {
throw new Error('Refusing to fetch a private or local address.');
}
return new Promise((resolve, reject) => {
const req = https.request(parsed, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9',
},
timeout: timeoutMs,
}, (res) => {
const location = res.headers.location;
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
const next = new URL(location, parsed).toString();
resolve(requestText(next, { timeoutMs, redirects: redirects + 1 }));
res.resume();
return;
}
const chunks = [];
let size = 0;
res.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_FETCH_BYTES) {
req.destroy(new Error('Response exceeded byte limit.'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
resolve(body);
});
});
req.on('timeout', () => req.destroy(new Error('Request timed out.')));
req.on('error', reject);
req.end();
});
}
function isPrivateHostname(hostname) {
let host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
if (host.startsWith('::ffff:')) host = host.slice(7);
if (
host === 'localhost'
|| host.endsWith('.localhost')
|| host.endsWith('.local')
|| host === '0.0.0.0'
|| host === '::1'
) {
return true;
}
const ipVersion = isIP(host);
if (!ipVersion) return false;
if (ipVersion === 4) {
const [a, b] = host.split('.').map((part) => Number(part));
return (
a === 10
|| a === 127
|| a === 0
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
);
}
return (
host === '::1'
|| host.startsWith('fc')
|| host.startsWith('fd')
|| host.startsWith('fe80')
);
}
function extractUrlsFromSearch(data) {
const urls = [];
const results = [];
const web = Array.isArray(data?.web?.results) ? data.web.results : [];
for (const item of web.slice(0, 8)) {
const url = String(item?.url || '').trim();
if (!url.startsWith('https://')) continue;
urls.push(url);
results.push({
title: String(item.title || '').slice(0, 200),
url,
description: String(item.description || '').slice(0, 500),
});
}
return { urls, results };
}
function stripHtml(value) {
return String(value || '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 8_000);
}
async function runSearch(query) {
const q = String(query || '').trim();
if (!q) throw new Error('Search query is required.');
if (q.length > 400) throw new Error('Search query is too long.');
const url = new URL(BRAVE_SEARCH_URL);
url.searchParams.set('q', q);
url.searchParams.set('count', '8');
url.searchParams.set('text_decorations', 'false');
url.searchParams.set('search_lang', 'en');
const data = await requestJson(url.toString(), {
headers: {
Accept: 'application/json',
'Accept-Encoding': 'identity',
'X-Subscription-Token': readApiKey(),
'User-Agent': USER_AGENT,
},
});
const extracted = extractUrlsFromSearch(data);
const event = {
ok: true,
action: 'search',
query: q,
urls: extracted.urls,
results: extracted.results,
};
appendToolLog(event);
return event;
}
async function runFetch(target) {
const url = String(target || '').trim();
if (!url.startsWith('https://')) throw new Error('Fetch URL must be https.');
const body = stripHtml(await requestText(url));
const event = {
ok: true,
action: 'fetch',
query: url,
urls: [url],
results: [{ title: url, url, description: body }],
};
appendToolLog(event);
return event;
}
async function main(argv) {
const action = String(argv[2] || '').toLowerCase();
const input = argv.slice(3).join(' ').trim();
if (action !== 'search' && action !== 'fetch') {
throw new Error('Usage: ai-brave-search.cjs <search|fetch> <query-or-url>');
}
const event = action === 'search' ? await runSearch(input) : await runFetch(input);
process.stdout.write(`${JSON.stringify(event, null, 2)}\n`);
}
module.exports = {
extractUrlsFromSearch,
isPrivateHostname,
stripHtml,
runSearch,
runFetch,
};
if (require.main === module) {
main(process.argv).catch((error) => {
const failed = {
ok: false,
action: String(process.argv[2] || ''),
query: process.argv.slice(3).join(' '),
error: error.message,
};
try { appendToolLog(failed); } catch { /* ignore log failure */ }
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,34 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const brave = require('./ai-brave-search.cjs');
test('isPrivateHostname blocks loopback and RFC1918', () => {
assert.equal(brave.isPrivateHostname('localhost'), true);
assert.equal(brave.isPrivateHostname('127.0.0.1'), true);
assert.equal(brave.isPrivateHostname('10.0.0.8'), true);
assert.equal(brave.isPrivateHostname('192.168.1.9'), true);
assert.equal(brave.isPrivateHostname('::ffff:127.0.0.1'), true);
assert.equal(brave.isPrivateHostname('[::1]'), true);
assert.equal(brave.isPrivateHostname('example.com'), false);
});
test('extractUrlsFromSearch keeps https results only', () => {
const extracted = brave.extractUrlsFromSearch({
web: {
results: [
{ title: 'Docs', url: 'https://example.com/docs', description: 'Official' },
{ title: 'Insecure', url: 'http://example.com/old', description: 'Skip' },
],
},
});
assert.deepEqual(extracted.urls, ['https://example.com/docs']);
assert.equal(extracted.results[0].title, 'Docs');
});
test('stripHtml removes tags and scripts', () => {
const text = brave.stripHtml('<html><script>alert(1)</script><p>Hello world</p></html>');
assert.match(text, /Hello world/);
assert.doesNotMatch(text, /alert/);
});

View File

@@ -0,0 +1,90 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
buildWindowsHelloHelper,
normalizeWindowsHelperArch,
} = require("./build-windows-hello-helper.cjs");
const CURSOR_PLATFORM_PACKAGES = {
darwin: ["@cursor/sdk-darwin-arm64", "@cursor/sdk-darwin-x64"],
linux: ["@cursor/sdk-linux-arm64", "@cursor/sdk-linux-x64"],
win32: ["@cursor/sdk-win32-x64"],
};
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function getCursorSdkVersion(projectDir) {
const sdkPackagePath = path.join(projectDir, "node_modules", "@cursor", "sdk", "package.json");
if (fs.existsSync(sdkPackagePath)) {
return readJson(sdkPackagePath).version;
}
const lockPath = path.join(projectDir, "package-lock.json");
if (fs.existsSync(lockPath)) {
const lock = readJson(lockPath);
const lockedVersion = lock.packages?.["node_modules/@cursor/sdk"]?.version;
if (lockedVersion) return lockedVersion;
}
const packageJson = readJson(path.join(projectDir, "package.json"));
const spec = packageJson.optionalDependencies?.["@cursor/sdk"];
return typeof spec === "string" ? spec.replace(/^[^\d]*/, "") : null;
}
function npmExecutable() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function ensureCursorSdkPlatformPackages({
projectDir,
platform,
run = execFileSync,
logger = console,
}) {
const packages = CURSOR_PLATFORM_PACKAGES[platform] || [];
if (packages.length === 0) return [];
const version = getCursorSdkVersion(projectDir);
if (!version) {
logger.warn("[beforePackCursorSdk] Cursor SDK version not found; skipping platform package install.");
return [];
}
const missingPackages = packages.filter((packageName) => (
!fs.existsSync(path.join(projectDir, "node_modules", ...packageName.split("/"), "package.json"))
));
if (missingPackages.length === 0) return [];
const packageSpecs = missingPackages.map((packageName) => `${packageName}@${version}`);
logger.log(`[beforePackCursorSdk] Installing Cursor SDK platform packages: ${packageSpecs.join(", ")}`);
run(npmExecutable(), ["install", "--no-save", "--force", "--ignore-scripts", ...packageSpecs], {
cwd: projectDir,
stdio: "inherit",
});
return missingPackages;
}
function beforePackCursorSdk(context = {}) {
const projectDir = context.appDir || process.cwd();
const platform = context.electronPlatformName || process.platform;
const arch = normalizeWindowsHelperArch(context.arch || process.env.npm_config_arch || process.arch);
const ensureCursor = context.ensureCursorSdkPlatformPackages || ensureCursorSdkPlatformPackages;
ensureCursor({ projectDir, platform });
const buildHelper = context.buildWindowsHelloHelper || buildWindowsHelloHelper;
if (platform === "win32") {
const result = buildHelper({ projectDir, platform, arch });
if (result?.skipped) {
throw new Error(`Windows Hello helper was not built: ${result.reason || "unknown"}`);
}
}
}
module.exports = beforePackCursorSdk;
module.exports.default = beforePackCursorSdk;
module.exports.beforePackCursorSdk = beforePackCursorSdk;
module.exports.ensureCursorSdkPlatformPackages = ensureCursorSdkPlatformPackages;
module.exports.CURSOR_PLATFORM_PACKAGES = CURSOR_PLATFORM_PACKAGES;

View File

@@ -0,0 +1,214 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
CURSOR_PLATFORM_PACKAGES,
beforePackCursorSdk,
ensureCursorSdkPlatformPackages,
} = require("./beforePackCursorSdk.cjs");
const {
copyPatchedNodePtyToPackagedApp,
rebuildPatchedNodePty,
} = require("./nodePtyConptyPatch.cjs");
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
test("ensureCursorSdkPlatformPackages installs both macOS Cursor runtime packages", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-pack-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
writeJson(path.join(tempDir, "node_modules", "@cursor", "sdk", "package.json"), { version: "1.0.18" });
writeJson(path.join(tempDir, "node_modules", "@cursor", "sdk-darwin-arm64", "package.json"), { version: "1.0.18" });
const calls = [];
const installed = ensureCursorSdkPlatformPackages({
projectDir: tempDir,
platform: "darwin",
run: (...args) => calls.push(args),
logger: { log() {}, warn() {} },
});
assert.deepEqual(installed, ["@cursor/sdk-darwin-x64"]);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], process.platform === "win32" ? "npm.cmd" : "npm");
assert.deepEqual(calls[0][1], [
"install",
"--no-save",
"--force",
"--ignore-scripts",
"@cursor/sdk-darwin-x64@1.0.18",
]);
assert.equal(calls[0][2].cwd, tempDir);
});
test("ensureCursorSdkPlatformPackages is a no-op when target packages exist", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cursor-pack-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
writeJson(path.join(tempDir, "node_modules", "@cursor", "sdk", "package.json"), { version: "1.0.18" });
for (const packageName of CURSOR_PLATFORM_PACKAGES.linux) {
writeJson(path.join(tempDir, "node_modules", ...packageName.split("/"), "package.json"), { version: "1.0.18" });
}
const calls = [];
const installed = ensureCursorSdkPlatformPackages({
projectDir: tempDir,
platform: "linux",
run: (...args) => calls.push(args),
logger: { log() {}, warn() {} },
});
assert.deepEqual(installed, []);
assert.deepEqual(calls, []);
});
test("beforePackCursorSdk builds Windows Hello helper only for Windows packages", () => {
const calls = [];
beforePackCursorSdk({
appDir: process.cwd(),
electronPlatformName: "win32",
arch: 3,
ensureCursorSdkPlatformPackages: () => [],
buildWindowsHelloHelper: (projectDir) => calls.push(projectDir),
});
assert.deepEqual(calls, [{ projectDir: process.cwd(), platform: "win32", arch: "arm64" }]);
beforePackCursorSdk({
appDir: process.cwd(),
electronPlatformName: "darwin",
ensureCursorSdkPlatformPackages: () => [],
buildWindowsHelloHelper: (projectDir) => calls.push(projectDir),
});
assert.deepEqual(calls, [{ projectDir: process.cwd(), platform: "win32", arch: "arm64" }]);
});
test("beforePackCursorSdk falls back to npm_config_arch for Windows Hello helper arch", () => {
const calls = [];
const originalArch = process.env.npm_config_arch;
process.env.npm_config_arch = "x64";
try {
beforePackCursorSdk({
appDir: process.cwd(),
electronPlatformName: "win32",
ensureCursorSdkPlatformPackages: () => [],
buildWindowsHelloHelper: (projectDir) => calls.push(projectDir),
});
} finally {
if (originalArch === undefined) {
delete process.env.npm_config_arch;
} else {
process.env.npm_config_arch = originalArch;
}
}
assert.deepEqual(calls, [{ projectDir: process.cwd(), platform: "win32", arch: "x64" }]);
});
test("beforePackCursorSdk fails Windows packaging when Windows Hello helper build is skipped", () => {
assert.throws(
() => beforePackCursorSdk({
appDir: process.cwd(),
electronPlatformName: "win32",
ensureCursorSdkPlatformPackages: () => [],
buildWindowsHelloHelper: () => ({ skipped: true, reason: "compiler-unavailable" }),
}),
/Windows Hello helper was not built: compiler-unavailable/,
);
});
test("Windows packaging rebuilds patched node-pty from source for the target architecture", () => {
const calls = [];
const rebuilt = rebuildPatchedNodePty({
projectDir: "/workspace/netcatty",
platform: "win32",
arch: 3,
run: (...args) => calls.push(args),
exists: () => true,
logger: { log() {} },
});
assert.equal(rebuilt, true);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], process.execPath);
assert.deepEqual(calls[0][1], [
path.join("/workspace/netcatty", "node_modules", "@electron", "rebuild", "lib", "cli.js"),
"--force",
"--build-from-source",
"--only",
"node-pty",
"--arch",
"arm64",
]);
assert.equal(calls[0][2].cwd, "/workspace/netcatty");
assert.equal(calls[1][0], process.execPath);
assert.equal(
calls[1][1][0],
path.join("/workspace/netcatty", "node_modules", "node-pty", "scripts", "post-install.js"),
);
assert.equal(calls[1][2].env.npm_config_arch, "arm64");
});
test("Windows packaging fails when rebuilt node-pty runtime files are incomplete", () => {
assert.throws(() => rebuildPatchedNodePty({
projectDir: "/workspace/netcatty",
platform: "win32",
arch: 1,
run() {},
exists: (filePath) => !filePath.endsWith("conpty.dll"),
logger: { log() {} },
}), /Patched node-pty artifacts missing: .*conpty\.dll/);
});
test("non-Windows packaging keeps the prebuilt node-pty path", () => {
const calls = [];
const rebuilt = rebuildPatchedNodePty({
projectDir: "/workspace/netcatty",
platform: "linux",
arch: 1,
run: (...args) => calls.push(args),
logger: { log() {} },
});
assert.equal(rebuilt, false);
assert.deepEqual(calls, []);
});
test("Windows afterPack copies rebuilt ConPTY files over packaged prebuilds", () => {
const copied = [];
const made = [];
const destinations = copyPatchedNodePtyToPackagedApp({
projectDir: "/workspace/netcatty",
resourcesDir: "/workspace/release/resources",
copy: (...args) => copied.push(args),
mkdir: (...args) => made.push(args),
});
assert.equal(copied.length, 3);
assert.equal(made.length, 3);
assert.equal(copied[0][0], path.join(
"/workspace/netcatty", "node_modules", "node-pty", "build", "Release", "conpty.node",
));
assert.equal(copied[0][1], path.join(
"/workspace/release/resources", "app.asar.unpacked", "node_modules", "node-pty", "build", "Release", "conpty.node",
));
assert.deepEqual(destinations, copied.map(([, destination]) => destination));
});
test("node-pty patch matches bundled ConPTY clear ABI and preserves the cursor row", () => {
const patch = fs.readFileSync(
path.join(__dirname, "..", "patches", "node-pty+1.1.0.patch"),
"utf8",
);
assert.match(patch, /ConptyClearPseudoConsole\(HPCON hPC, BOOL keepCursorRow\)/);
assert.match(patch, /PFNCLEARPSEUDOCONSOLE\)\(HPCON hpc, BOOL keepCursorRow\)/);
assert.match(patch, /pfnClearPseudoConsole\(handle->hpc, TRUE\)/);
assert.doesNotMatch(patch, /node_modules\/node-pty\/build\//);
});

View File

@@ -0,0 +1,62 @@
import {
applyConvergentMutations,
createConvergentSyncState,
mergeConvergentSyncStates,
type ConvergentMutation,
} from '../domain/convergentSync/index.ts';
function elapsedMs<T>(operation: () => T): { value: T; duration: number } {
const startedAt = performance.now();
const value = operation();
return { value, duration: performance.now() - startedAt };
}
function buildMutations(count: number): ConvergentMutation[] {
return Array.from({ length: count }, (_, index) => ({
kind: 'entity-upsert' as const,
collection: 'hosts',
entityId: `host-${index.toString().padStart(5, '0')}`,
value: {
id: `host-${index.toString().padStart(5, '0')}`,
label: `Host ${index}`,
hostname: `host-${index}.example.com`,
tags: [`group-${index % 20}`],
},
position: index,
}));
}
function run(size: number): void {
const created = elapsedMs(() => applyConvergentMutations(
createConvergentSyncState(),
'seed',
buildMutations(size),
1_700_000_000_000,
));
const left = applyConvergentMutations(created.value, 'device-a', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: `host-${Math.floor(size / 3).toString().padStart(5, '0')}`,
field: 'label',
value: 'Edited on A',
}], 1_700_000_000_001);
const right = applyConvergentMutations(created.value, 'device-b', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: `host-${Math.floor(size / 2).toString().padStart(5, '0')}`,
field: 'hostname',
value: 'edited.example.com',
}], 1_700_000_000_001);
const merged = elapsedMs(() => mergeConvergentSyncStates(left, right));
console.log(JSON.stringify({
entities: size,
registers: Object.values(merged.value.collections.hosts.entities)
.reduce((total, entity) => total + 1 + (entity.position ? 1 : 0)
+ Object.keys(entity.fields).length, 0),
createMs: Number(created.duration.toFixed(2)),
mergeMs: Number(merged.duration.toFixed(2)),
}));
}
for (const size of [1_000, 5_000, 10_000]) run(size);

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Build a portable EternalTerminal `et` client inside manylinux2014.
#
# Inputs (env):
# ET_REF — git ref of MisterTea/EternalTerminal to build (e.g. et-v6.2.10)
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
# OUT_DIR — directory to write et-linux-<arch>.tar.gz + sha256
#
# Output:
# $OUT_DIR/et-linux-<arch>.tar.gz (single `et` client binary)
# $OUT_DIR/et-linux-<arch>.tar.gz.sha256
#
# Strategy: build inside manylinux2014 (glibc 2.17) for broad distro
# compatibility. EternalTerminal vendors vcpkg under external/vcpkg and uses
# manifest mode, so its third-party deps (protobuf, libsodium, openssl, ...)
# are built as static archives by vcpkg's x64-linux / arm64-linux triplet.
# The resulting `et` still depends on baseline Linux system libraries
# (glibc family), compatible with virtually every distro since 2014.
#
# `et` is a pure network-transport client; it renders no terminal locally and
# needs no terminfo database, so the bundle ships only the binary.
set -euo pipefail
: "${ET_REF:?missing ET_REF}"
: "${ARCH:?missing ARCH}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_et_ref() {
if [[ ! "$ET_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$ET_REF" == *..* ]] \
|| [[ "$ET_REF" == *@\{* ]] \
|| [[ "$ET_REF" == */ ]] \
|| [[ "$ET_REF" == *.lock ]]; then
echo "ERROR: invalid ET_REF: $ET_REF" >&2
exit 1
fi
}
validate_et_ref
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$OUT_DIR"
# manylinux2014 ships a devtoolset gcc and git, but an old cmake/ninja.
# Install modern cmake + ninja from PyPI (vcpkg requires cmake >= 3.x).
yum install -y -q zip unzip tar curl perl-IPC-Cmd >/dev/null 2>&1 || true
# manylinux ships CPython interpreters under /opt/python/<tag>/bin but puts
# none of them on PATH (a bare `python3` fails with 127). Prefer a known
# *stable* cpXY: picking "newest" would grab pre-release builds such as
# 3.15.0b1, which we don't want driving the cmake/ninja install.
if ! command -v python3 >/dev/null 2>&1; then
for tag in cp313 cp312 cp311 cp310; do
if [ -x "/opt/python/$tag-$tag/bin/python3" ]; then
export PATH="/opt/python/$tag-$tag/bin:$PATH"
break
fi
done
fi
command -v python3 >/dev/null 2>&1 \
|| { echo "ERROR: no stable python3 under /opt/python (manylinux layout changed?)" >&2; exit 1; }
python3 -m pip install --quiet --upgrade pip
# Pin cmake < 4: ET's pinned vcpkg baseline and some ports don't configure
# cleanly under cmake 4.x. ninja is unconstrained.
python3 -m pip install --quiet "cmake>=3.25,<4" ninja
export PATH="$(python3 -c 'import sysconfig,os;print(os.path.join(sysconfig.get_path("scripts")))'):$PATH"
NINJA_BIN=$(command -v ninja)
retry_command() {
local attempt=1
local max_attempts=4
local delay=15
until "$@"; do
local status=$?
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
echo "WARN: command failed with exit $status; retrying in ${delay}s (attempt $((attempt + 1))/$max_attempts): $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
cd "$WORK"
# Fetch EternalTerminal at the requested ref, with the vendored vcpkg
# submodule. Branch names, tags, and commit SHAs all work.
git init et
git -C et remote add origin https://github.com/MisterTea/EternalTerminal.git
git -C et fetch --depth 1 origin "$ET_REF"
git -C et checkout --detach FETCH_HEAD
git -C et submodule update --init --recursive --depth 1
# Drop sentry-native from the vcpkg manifest. We build with
# -DDISABLE_TELEMETRY=ON, so ET's CMake never calls find_package(sentry) nor
# links it; but vcpkg's manifest mode still force-builds every listed dep
# during configure. sentry-native pulls in crashpad, is the heaviest dep, and
# fails to build on arm64-linux — dropping it fixes arm64 and speeds up all.
if ! grep -q '"sentry-native"' "$WORK/et/vcpkg.json"; then
echo "ERROR: sentry-native not in vcpkg.json (ET manifest changed?)" >&2; exit 1
fi
grep -v '"sentry-native"' "$WORK/et/vcpkg.json" > "$WORK/et/vcpkg.json.tmp"
mv "$WORK/et/vcpkg.json.tmp" "$WORK/et/vcpkg.json"
# Build only the Release halves of the vcpkg deps (skip the Debug pass) to
# roughly halve build time. Overlay triplets mirror ET's chosen community
# triplet but force release-only; selected via VCPKG_OVERLAY_TRIPLETS so the
# vendored vcpkg tree stays untouched.
OVERLAY="$WORK/vcpkg-overlay-triplets"
mkdir -p "$OVERLAY"
for t in x64-linux arm64-linux; do
src=$(find "$WORK/et/external/vcpkg/triplets" -name "$t.cmake" | head -1)
[ -n "$src" ] || { echo "ERROR: vcpkg triplet $t.cmake not found" >&2; exit 1; }
cp "$src" "$OVERLAY/$t.cmake"
echo 'set(VCPKG_BUILD_TYPE release)' >> "$OVERLAY/$t.cmake"
done
export VCPKG_OVERLAY_TRIPLETS="$OVERLAY"
# Bootstrap the vendored vcpkg so CMake's vcpkg toolchain can resolve the
# manifest deps.
( cd et && ./external/vcpkg/bootstrap-vcpkg.sh -disableMetrics )
BUILD_DIR="$WORK/et/build"
# ET's CMake sets its own vcpkg toolchain + triplet (auto-detected from
# uname -m); we supply only the generator + build type. DISABLE_TELEMETRY=ON
# keeps ET from using Sentry, matching the manifest edit above.
#
# CMAKE_CXX_STANDARD_LIBRARIES=-lanl: ET (via cpp-httplib) references glibc's
# async DNS resolver getaddrinfo_a / gai_* (which live in libanl), but ET's
# link line omits -lanl, so linking `et` fails with "undefined reference to
# getaddrinfo_a". STANDARD_LIBRARIES is appended after all other libraries —
# exactly where the linker needs it to resolve those symbols.
retry_command cmake -S "$WORK/et" -B "$BUILD_DIR" \
-GNinja \
-DCMAKE_MAKE_PROGRAM="$NINJA_BIN" \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DDISABLE_TELEMETRY=ON \
-DCMAKE_CXX_STANDARD_LIBRARIES=-lanl
cmake --build "$BUILD_DIR" --target et
BUNDLE_DIR="$WORK/linux-$ARCH-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/et"
cp "$BUILD_DIR/et" "$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 (matches the mosh build policy).
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|stdc\+\+|gcc_s|atomic|anl)\.so\.[0-9]+|/lib.*/ld-linux.*\.so\.[0-9]+|ld-linux.*\.so\.[0-9]+)$' "$WORK/deps.txt"; then
echo "ERROR: et links a non-system shared library; static linking failed." >&2
exit 1
fi
BUNDLE_TGZ="$OUT_DIR/et-linux-$ARCH.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "et" )
( cd "$OUT_DIR" && sha256sum "et-linux-$ARCH.tar.gz" > "et-linux-$ARCH.tar.gz.sha256" )
cat "$OUT_DIR/et-linux-$ARCH.tar.gz.sha256"

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Build a universal EternalTerminal `et` client on macOS (arm64 + x86_64).
#
# Inputs (env):
# ET_REF — git ref of MisterTea/EternalTerminal to build (e.g. et-v6.2.10)
# OUT_DIR — directory to write et-darwin-universal.tar.gz + sha256
# MACOSX_DEPLOYMENT_TARGET — min macOS (default 11.0)
#
# Output:
# $OUT_DIR/et-darwin-universal.tar.gz (single universal `et`)
# $OUT_DIR/et-darwin-universal.tar.gz.sha256
#
# Builds each arch separately (vcpkg arm64-osx / x64-osx static triplets) and
# lipo-combines the two `et` binaries. Links only macOS system dylibs.
set -euo pipefail
: "${ET_REF:?missing ET_REF}"
: "${OUT_DIR:?missing OUT_DIR}"
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}"
validate_et_ref() {
if [[ ! "$ET_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$ET_REF" == *..* ]] \
|| [[ "$ET_REF" == *@\{* ]] \
|| [[ "$ET_REF" == */ ]] \
|| [[ "$ET_REF" == *.lock ]]; then
echo "ERROR: invalid ET_REF: $ET_REF" >&2
exit 1
fi
}
validate_et_ref
command -v ninja >/dev/null 2>&1 || brew install ninja
command -v cmake >/dev/null 2>&1 || brew install cmake
command -v autoconf >/dev/null 2>&1 || brew install automake autoconf libtool
NINJA_BIN=$(command -v ninja)
retry_command() {
local attempt=1
local max_attempts=4
local delay=15
until "$@"; do
local status=$?
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
echo "WARN: command failed with exit $status; retrying in ${delay}s (attempt $((attempt + 1))/$max_attempts): $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$OUT_DIR"
cd "$WORK"
git init et
git -C et remote add origin https://github.com/MisterTea/EternalTerminal.git
git -C et fetch --depth 1 origin "$ET_REF"
git -C et checkout --detach FETCH_HEAD
git -C et submodule update --init --recursive --depth 1
# Drop sentry-native from the vcpkg manifest — see build-linux.sh for the
# full rationale. -DDISABLE_TELEMETRY=ON means ET never references Sentry,
# yet vcpkg's manifest would otherwise force-build it (and crashpad) anyway.
if ! grep -q '"sentry-native"' "$WORK/et/vcpkg.json"; then
echo "ERROR: sentry-native not in vcpkg.json (ET manifest changed?)" >&2; exit 1
fi
grep -v '"sentry-native"' "$WORK/et/vcpkg.json" > "$WORK/et/vcpkg.json.tmp"
mv "$WORK/et/vcpkg.json.tmp" "$WORK/et/vcpkg.json"
# Release-only vcpkg deps (skip the Debug pass) to halve build time, via
# overlay triplets that mirror the osx triplets but force release-only.
OVERLAY="$WORK/vcpkg-overlay-triplets"
mkdir -p "$OVERLAY"
for t in arm64-osx x64-osx; do
src=$(find "$WORK/et/external/vcpkg/triplets" -name "$t.cmake" | head -1)
[ -n "$src" ] || { echo "ERROR: vcpkg triplet $t.cmake not found" >&2; exit 1; }
cp "$src" "$OVERLAY/$t.cmake"
echo 'set(VCPKG_BUILD_TYPE release)' >> "$OVERLAY/$t.cmake"
done
export VCPKG_OVERLAY_TRIPLETS="$OVERLAY"
( cd et && ./external/vcpkg/bootstrap-vcpkg.sh -disableMetrics )
build_arch() {
local arch="$1" # arm64 | x86_64
local triplet="$2" # arm64-osx | x64-osx
local build_dir="$WORK/build-$arch"
echo "=== building et for $arch ($triplet) ===" >&2
retry_command cmake -S "$WORK/et" -B "$build_dir" \
-GNinja \
-DCMAKE_MAKE_PROGRAM="$NINJA_BIN" \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DDISABLE_TELEMETRY=ON \
-DCMAKE_OSX_ARCHITECTURES="$arch" \
-DVCPKG_TARGET_TRIPLET="$triplet"
cmake --build "$build_dir" --target et
echo "$build_dir/et"
}
ARM_BIN=$(build_arch arm64 arm64-osx | tail -1)
X64_BIN=$(build_arch x86_64 x64-osx | tail -1)
BUNDLE_DIR="$WORK/darwin-universal-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/et"
lipo -create -output "$OUT_BIN" "$ARM_BIN" "$X64_BIN"
strip "$OUT_BIN" || true
echo "--- lipo info ---"
lipo -info "$OUT_BIN"
echo "--- otool -L ---"
otool -L "$OUT_BIN" || true
# Sanity check: only macOS system dylibs (/usr/lib, /System/Library) allowed.
# A universal binary makes `otool -L` print a "<path> (architecture X):"
# header per slice; key off the "(compatibility version ...)" suffix that only
# real dependency lines carry, so those per-arch headers aren't misread as a
# non-system dylib (tail -n +2 only drops the first one).
if otool -L "$OUT_BIN" | awk '/\(compatibility version/ {print $1}' \
| grep -Ev '^(/usr/lib/|/System/Library/)' | grep -q .; then
echo "ERROR: et links a non-system dylib; static linking failed." >&2
otool -L "$OUT_BIN" >&2
exit 1
fi
BUNDLE_TGZ="$OUT_DIR/et-darwin-universal.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "et" )
( cd "$OUT_DIR" && shasum -a 256 "et-darwin-universal.tar.gz" > "et-darwin-universal.tar.gz.sha256" )
cat "$OUT_DIR/et-darwin-universal.tar.gz.sha256"

View File

@@ -0,0 +1,129 @@
# Build a static EternalTerminal `et` client on Windows (x64, MSVC).
#
# Inputs (env):
# ET_REF — git ref of MisterTea/EternalTerminal to build (e.g. et-v6.2.10)
# OUT_DIR — directory to write et-win32-x64.tar.gz + sha256
#
# Output:
# $OUT_DIR/et-win32-x64.tar.gz (single static et.exe, no DLLs)
# $OUT_DIR/et-win32-x64.tar.gz.sha256
#
# Uses the vendored vcpkg x64-windows-static triplet so the produced et.exe
# statically links the MSVC runtime and all third-party deps — no DLL bundle
# is needed. Run from a Developer Command Prompt (ilammy/msvc-dev-cmd) so
# cl.exe / ninja are on PATH.
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if (-not $env:ET_REF) { throw "missing ET_REF" }
if (-not $env:OUT_DIR) { throw "missing OUT_DIR" }
function Invoke-WithRetry {
param(
[Parameter(Mandatory = $true)]
[scriptblock]$Command,
[int]$MaxAttempts = 4,
[int]$InitialDelaySeconds = 15
)
$attempt = 1
$delay = $InitialDelaySeconds
while ($true) {
& $Command
if ($LASTEXITCODE -eq 0) { return }
if ($attempt -ge $MaxAttempts) {
throw "command failed after $attempt attempts (exit $LASTEXITCODE)"
}
Write-Warning "command failed with exit $LASTEXITCODE; retrying in ${delay}s (attempt $($attempt + 1)/$MaxAttempts)"
Start-Sleep -Seconds $delay
$attempt++
$delay *= 2
}
}
$etRef = $env:ET_REF
if ($etRef -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $etRef -match '\.\.' -or $etRef -match '@\{' -or $etRef.EndsWith('/') -or $etRef.EndsWith('.lock')) {
throw "invalid ET_REF: $etRef"
}
# Root the build just under the drive root. vcpkg unpacks dependencies into
# <work>\et\external_imported\vcpkg\buildtrees\... and libsodium's bundled
# MSBuild project pulls sources via long "..\..\..\..\src\..." relative paths.
# Rooted in %TEMP% (~60 chars) the unnormalized path exceeds Windows MAX_PATH
# (260) and fails with "C1083: Cannot open source file". A short drive-root
# (e.g. C:\et-XXXXXXXX) keeps every path comfortably under the limit.
$work = "$env:SystemDrive\et-" + [System.Guid]::NewGuid().ToString("N").Substring(0, 8)
if (Test-Path $work) { Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Force -Path $work | Out-Null
New-Item -ItemType Directory -Force -Path $env:OUT_DIR | Out-Null
try {
$etDir = Join-Path $work "et"
git init $etDir
git -C $etDir remote add origin https://github.com/MisterTea/EternalTerminal.git
git -C $etDir fetch --depth 1 origin $etRef
git -C $etDir checkout --detach FETCH_HEAD
git -C $etDir submodule update --init --recursive --depth 1
# Drop sentry-native from the vcpkg manifest. We configure with
# -DDISABLE_TELEMETRY=ON so ET never references Sentry, but vcpkg's manifest
# mode would still force-build it (and crashpad). Removing it avoids an
# unused heavy dependency and speeds up the build.
$manifest = Join-Path $etDir "vcpkg.json"
if (-not (Select-String -Path $manifest -Pattern '"sentry-native"' -Quiet)) {
throw "sentry-native not in vcpkg.json (ET manifest changed?)"
}
(Get-Content $manifest) | Where-Object { $_ -notmatch '"sentry-native"' } | Set-Content $manifest
# Build only the Release halves of the vcpkg deps (skip Debug) to roughly
# halve build time, via an overlay triplet mirroring x64-windows-static but
# forcing release-only.
$overlay = Join-Path $work "vcpkg-overlay-triplets"
New-Item -ItemType Directory -Force -Path $overlay | Out-Null
$srcTriplet = Join-Path $etDir "external\vcpkg\triplets\x64-windows-static.cmake"
if (-not (Test-Path $srcTriplet)) {
$srcTriplet = Join-Path $etDir "external\vcpkg\triplets\community\x64-windows-static.cmake"
}
if (-not (Test-Path $srcTriplet)) { throw "vcpkg triplet x64-windows-static.cmake not found" }
Copy-Item $srcTriplet (Join-Path $overlay "x64-windows-static.cmake")
Add-Content -Path (Join-Path $overlay "x64-windows-static.cmake") -Value 'set(VCPKG_BUILD_TYPE release)'
$env:VCPKG_OVERLAY_TRIPLETS = $overlay
& (Join-Path $etDir "external\vcpkg\bootstrap-vcpkg.bat") -disableMetrics
$buildDir = Join-Path $etDir "build"
Invoke-WithRetry {
cmake -S $etDir -B $buildDir `
-GNinja `
-DCMAKE_BUILD_TYPE=RelWithDebInfo `
-DDISABLE_TELEMETRY=ON `
-DVCPKG_TARGET_TRIPLET=x64-windows-static
}
cmake --build $buildDir --target et
if ($LASTEXITCODE -ne 0) { throw "cmake build failed" }
$bundleDir = Join-Path $work "win32-x64-bundle"
New-Item -ItemType Directory -Force -Path $bundleDir | Out-Null
$srcExe = Join-Path $buildDir "et.exe"
if (-not (Test-Path $srcExe)) { $srcExe = Join-Path $buildDir "RelWithDebInfo\et.exe" }
Copy-Item $srcExe (Join-Path $bundleDir "et.exe")
# Report any non-system DLL imports (informational; a static build should
# only import the in-box Windows DLLs).
Write-Host "--- et.exe built ---"
Get-Item (Join-Path $bundleDir "et.exe") | Format-List Name, Length
$tgz = Join-Path $env:OUT_DIR "et-win32-x64.tar.gz"
# Windows ships bsdtar as tar.exe.
tar -czf $tgz -C $bundleDir "et.exe"
if ($LASTEXITCODE -ne 0) { throw "tar failed" }
$hash = (Get-FileHash -Algorithm SHA256 $tgz).Hash.ToLower()
$sumLine = "$hash et-win32-x64.tar.gz"
Set-Content -Path (Join-Path $env:OUT_DIR "et-win32-x64.tar.gz.sha256") -Value $sumLine -NoNewline
Write-Host $sumLine
}
finally {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

View File

@@ -0,0 +1,182 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function findCompiler(env = process.env) {
if (env.CXX) return env.CXX;
if (env.CL) return "cl.exe";
return "cl.exe";
}
function quoteCmdArg(value) {
return `"${String(value).replace(/"/g, '\\"')}"`;
}
function makeCmdScriptPath(tmpdir, targetArch) {
return path.win32.join(tmpdir(), `build-netcatty-windows-hello-${targetArch}.cmd`);
}
function visualStudioDevCmdCandidates(env = process.env) {
const candidates = [];
if (env.VSINSTALLDIR) {
candidates.push(path.win32.join(env.VSINSTALLDIR, "Common7", "Tools", "VsDevCmd.bat"));
}
const programFilesRoots = [
env.ProgramFiles,
env["ProgramFiles(x86)"],
"C:\\Program Files",
"C:\\Program Files (x86)",
].filter(Boolean);
const versions = ["2026", "2022", "2019"];
const editions = ["Enterprise", "Professional", "Community", "BuildTools", "Preview"];
for (const root of programFilesRoots) {
for (const version of versions) {
for (const edition of editions) {
candidates.push(
path.win32.join(root, "Microsoft Visual Studio", version, edition, "Common7", "Tools", "VsDevCmd.bat"),
);
}
}
}
return [...new Set(candidates)];
}
function findVisualStudioDevCmd(env = process.env, existsSync = fs.existsSync) {
return visualStudioDevCmdCandidates(env).find((candidate) => existsSync(candidate)) || null;
}
function hasInitializedMsvcEnvironment(env = process.env, targetArch) {
const initializedArch = String(env.VSCMD_ARG_TGT_ARCH || "").toLowerCase();
if (initializedArch) return initializedArch === targetArch;
return Boolean(env.VSCMD_VER && env.INCLUDE && env.LIB);
}
function normalizeWindowsHelperArch(arch) {
if (arch === "x64" || arch === "arm64") return arch;
if (arch === 1 || arch === "1") return "x64";
if (arch === 3 || arch === "3") return "arm64";
return null;
}
function getExpectedPeMachine(arch) {
if (arch === "x64") return 0x8664;
if (arch === "arm64") return 0xaa64;
return null;
}
function readPeMachine(input) {
const buffer = Buffer.isBuffer(input) ? input : fs.readFileSync(input);
if (buffer.length < 0x40 || buffer.toString("ascii", 0, 2) !== "MZ") return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset + 6 > buffer.length) return null;
if (buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") return null;
return buffer.readUInt16LE(peOffset + 4);
}
function buildWindowsHelloHelper({
projectDir = process.cwd(),
platform = process.platform,
arch = process.env.npm_config_arch || process.arch,
env = process.env,
run = execFileSync,
mkdir = fs.mkdirSync,
writeFile = fs.writeFileSync,
rm = fs.rmSync,
tmpdir = require("node:os").tmpdir,
existsSync = fs.existsSync,
readMachine = readPeMachine,
logger = console,
} = {}) {
if (platform !== "win32") return { skipped: true, reason: "non-windows" };
const targetArch = normalizeWindowsHelperArch(arch);
if (!targetArch) return { skipped: true, reason: "unsupported-arch" };
const pathApi = platform === "win32" ? path.win32 : path;
const sourcePath = pathApi.join(projectDir, "electron", "bridges", "windowsHelloHelper", "NetcattyWindowsHello.cpp");
const outputDir = pathApi.join(projectDir, "electron", "bridges", "windowsHelloHelper", "build", targetArch);
const outputPath = pathApi.join(outputDir, "NetcattyWindowsHello.exe");
mkdir(outputDir, { recursive: true });
const compiler = findCompiler(env);
const machine = targetArch === "arm64" ? "ARM64" : "X64";
const compilerArgs = [
"/nologo",
"/EHsc",
"/std:c++17",
"/D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS",
sourcePath,
"/Fe:" + outputPath,
"/link",
"/MACHINE:" + machine,
"runtimeobject.lib",
"windowsapp.lib",
];
try {
const shouldInitializeMsvc = compiler === "cl.exe" && !hasInitializedMsvcEnvironment(env, targetArch);
const vsDevCmd = shouldInitializeMsvc ? findVisualStudioDevCmd(env, existsSync) : null;
if (vsDevCmd) {
const devArch = targetArch === "arm64" ? "arm64" : "x64";
const compileCommand = [
"call",
quoteCmdArg(vsDevCmd),
`-arch=${devArch}`,
"-host_arch=x64",
"&&",
quoteCmdArg(compiler),
...compilerArgs.map(quoteCmdArg),
].join(" ");
const scriptPath = makeCmdScriptPath(tmpdir, targetArch);
writeFile(scriptPath, `@echo off\r\n${compileCommand}\r\n`, "utf8");
try {
run("cmd.exe", ["/d", "/c", scriptPath], {
cwd: projectDir,
stdio: "inherit",
env,
});
} finally {
rm(scriptPath, { force: true });
}
} else {
run(compiler, compilerArgs, {
cwd: projectDir,
stdio: "inherit",
env,
});
}
} catch (err) {
logger.warn?.(`[windowsHelloHelper] Failed to build Windows Hello helper: ${err?.message || err}`);
return { skipped: true, reason: "compiler-unavailable" };
}
const expectedMachine = getExpectedPeMachine(targetArch);
const actualMachine = readMachine(outputPath);
if (actualMachine !== expectedMachine) {
logger.warn?.(`[windowsHelloHelper] Built helper machine ${actualMachine} does not match ${targetArch}`);
return { skipped: true, reason: "wrong-arch" };
}
return { skipped: false, outputPath };
}
if (require.main === module) {
const result = buildWindowsHelloHelper();
if (result.skipped) {
console.log(`[windowsHelloHelper] skipped: ${result.reason}`);
} else {
console.log(`[windowsHelloHelper] built: ${result.outputPath}`);
}
}
module.exports = {
buildWindowsHelloHelper,
findCompiler,
findVisualStudioDevCmd,
getExpectedPeMachine,
hasInitializedMsvcEnvironment,
makeCmdScriptPath,
normalizeWindowsHelperArch,
readPeMachine,
visualStudioDevCmdCandidates,
};

View File

@@ -0,0 +1,154 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const {
buildWindowsHelloHelper,
getExpectedPeMachine,
normalizeWindowsHelperArch,
readPeMachine,
} = require("./build-windows-hello-helper.cjs");
test("normalizeWindowsHelperArch accepts only packaged Windows architectures", () => {
assert.equal(normalizeWindowsHelperArch("x64"), "x64");
assert.equal(normalizeWindowsHelperArch("arm64"), "arm64");
assert.equal(normalizeWindowsHelperArch(1), "x64");
assert.equal(normalizeWindowsHelperArch(3), "arm64");
assert.equal(normalizeWindowsHelperArch("ia32"), null);
assert.equal(normalizeWindowsHelperArch(""), null);
});
test("getExpectedPeMachine maps target helper architectures", () => {
assert.equal(getExpectedPeMachine("x64"), 0x8664);
assert.equal(getExpectedPeMachine("arm64"), 0xaa64);
assert.equal(getExpectedPeMachine("ia32"), null);
});
test("buildWindowsHelloHelper writes target architecture helper into an arch-specific directory", () => {
const calls = [];
const result = buildWindowsHelloHelper({
projectDir: "/repo",
platform: "win32",
arch: "arm64",
env: {},
run: (...args) => calls.push(args),
mkdir: () => {},
readMachine: () => 0xaa64,
logger: { warn() {} },
});
assert.equal(result.skipped, false);
assert.equal(
result.outputPath,
path.win32.join("\\repo", "electron", "bridges", "windowsHelloHelper", "build", "arm64", "NetcattyWindowsHello.exe"),
);
assert.match(
calls[0][1].join(" "),
/\/Fe:.*windowsHelloHelper.*build.*arm64.*NetcattyWindowsHello\.exe/,
);
assert.ok(
calls[0][1].includes("/D_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS"),
"Windows Hello helper compile must tolerate older C++/WinRT headers on newer MSVC",
);
assert.deepEqual(calls[0][1].slice(-4), ["/link", "/MACHINE:ARM64", "runtimeobject.lib", "windowsapp.lib"]);
});
test("buildWindowsHelloHelper initializes the Visual Studio developer environment when cl is not already on PATH", () => {
const calls = [];
const writes = [];
const removals = [];
const vsDevCmd = "C:\\Program Files\\Microsoft Visual Studio\\2026\\Enterprise\\Common7\\Tools\\VsDevCmd.bat";
const result = buildWindowsHelloHelper({
projectDir: "D:\\a\\Netcatty\\Netcatty",
platform: "win32",
arch: "x64",
env: {
ProgramFiles: "C:\\Program Files",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
},
existsSync: (candidate) => candidate === vsDevCmd,
run: (...args) => calls.push(args),
writeFile: (...args) => writes.push(args),
rm: (...args) => removals.push(args),
tmpdir: () => "D:\\a\\_temp",
mkdir: () => {},
readMachine: () => 0x8664,
logger: { warn() {} },
});
assert.equal(result.skipped, false);
assert.equal(calls[0][0], "cmd.exe");
assert.deepEqual(calls[0][1].slice(0, 3), ["/d", "/c", writes[0][0]]);
assert.match(writes[0][0], /build-netcatty-windows-hello-x64\.cmd$/);
assert.match(writes[0][1], /call "C:\\Program Files\\Microsoft Visual Studio\\2026\\Enterprise\\Common7\\Tools\\VsDevCmd\.bat"/);
assert.match(writes[0][1], /-arch=x64/);
assert.match(writes[0][1], /cl\.exe/);
assert.match(writes[0][1], /\/MACHINE:X64/);
assert.deepEqual(removals[0], [writes[0][0], { force: true }]);
});
test("buildWindowsHelloHelper uses the current MSVC environment when it is already initialized", () => {
const calls = [];
const vsDevCmd = "C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise\\Common7\\Tools\\VsDevCmd.bat";
const result = buildWindowsHelloHelper({
projectDir: "D:\\a\\Netcatty\\Netcatty",
platform: "win32",
arch: "x64",
env: {
VSINSTALLDIR: "C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise\\",
VSCMD_ARG_TGT_ARCH: "x64",
VSCMD_VER: "18.0.0",
},
existsSync: (candidate) => candidate === vsDevCmd,
run: (...args) => calls.push(args),
mkdir: () => {},
readMachine: () => 0x8664,
logger: { warn() {} },
});
assert.equal(result.skipped, false);
assert.equal(calls[0][0], "cl.exe");
assert.match(calls[0][1].join(" "), /\/MACHINE:X64/);
});
test("buildWindowsHelloHelper rejects unsupported target architectures on Windows", () => {
const result = buildWindowsHelloHelper({
projectDir: "/repo",
platform: "win32",
arch: "ia32",
mkdir: () => {
throw new Error("should not create output dir");
},
run: () => {
throw new Error("should not run compiler");
},
logger: { warn() {} },
});
assert.deepEqual(result, { skipped: true, reason: "unsupported-arch" });
});
test("buildWindowsHelloHelper rejects a built helper with the wrong PE machine", () => {
const result = buildWindowsHelloHelper({
projectDir: "/repo",
platform: "win32",
arch: "arm64",
mkdir: () => {},
run: () => {},
readMachine: () => 0x8664,
logger: { warn() {} },
});
assert.deepEqual(result, { skipped: true, reason: "wrong-arch" });
});
test("readPeMachine reads the PE COFF machine value", () => {
const buffer = Buffer.alloc(0x90);
buffer.write("MZ", 0, "ascii");
buffer.writeUInt32LE(0x80, 0x3c);
buffer.write("PE\0\0", 0x80, "ascii");
buffer.writeUInt16LE(0xaa64, 0x84);
assert.equal(readPeMachine(buffer), 0xaa64);
assert.equal(readPeMachine(Buffer.from("not-pe")), null);
});

View File

@@ -0,0 +1,12 @@
const fs = require('fs');
const path = require('path');
const cacheDir = path.join(__dirname, '..', 'node_modules', '.vite');
if (!fs.existsSync(cacheDir)) {
console.log('[clean-vite] No Vite cache to remove');
process.exit(0);
}
fs.rmSync(cacheDir, { recursive: true, force: true });
console.log('[clean-vite] Removed', cacheDir);

View File

@@ -0,0 +1,551 @@
'use strict';
const fs = require('node:fs');
const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
// Known app/test logger tags that emit volatile `# [Tag] ...` TAP comments on
// every npm test run. Skip these specifically; unknown bracket tags still enter
// nonTapOutput so new runner diagnostics keep failing the gate.
const VOLATILE_TAP_DIAG_TAG_RE = new RegExp(
'^# \\[(?:'
+ [
'transferDiag',
'transferBridge',
'FileWatcher',
'SessionLogStream',
'SSH',
'SSH Exec',
'KeyboardInteractive',
'Telnet',
'GlobalShortcut',
'Chain',
'PortForward',
'PortForwardingService',
'SFTP',
'SFTP Chain',
'TempDir',
'Terminal',
'AutoUpdate',
'Passphrase',
'Test',
'resolve-mosh-bin-release',
'resolve-et-bin-release',
'KeywordHighlight',
'Cursor SDK',
'Main',
'vaultBackupBridge',
'CloudSyncManager',
'ZMODEM',
'TrayPanel',
'sdk',
'scp-it',
'Plugins',
'netcatty-mcp',
'DirtyEditorGuard',
'Credentials',
'afterPack',
].map((tag) => tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
+ ')\\]',
);
function normalizeNonTapLine(line) {
return String(line || '')
.replace(/\(node:\d+\)/g, '(node:<pid>)')
.replace(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi,
'<uuid>',
)
.replace(/\bsftp-\d+\b/g, 'sftp-<id>')
.replace(
/\b(?:requestId|sessionId|transferId|id)\b(['"]?\s*[:=]\s*['"]?)[^'"\s,}\]]+/gi,
'$1<id>',
)
.replace(/https?:\/\/127\.0\.0\.1:\d+/g, 'http://127.0.0.1:<port>');
}
function normalizeRuntimeDetail(detail) {
return detail
.replace(
/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g,
'<timestamp>',
)
.replace(/(?:\/private)?\/var\/folders\/\S+|\/tmp\/\S+/g, '<tmp-path>')
.replace(/:\d+:\d+\b/g, ':<line>:<column>');
}
function normalizeAssertionDetail(detail) {
return normalizeRuntimeDetail(detail).replace(
/\b(now|timestamp|pid|nonce|random(?:Value)?)\b(['"]?\s*[:=]\s*)[^,\s}\]]+/gi,
'$1$2<volatile>',
);
}
function normalizeStableDiagnosticDetail(detail) {
return normalizeAssertionDetail(detail);
}
function collectNonTapOutput(lines) {
const output = [];
let inDiagnostic = false;
let diagnosticOwnerIndent = -1;
let awaitingDiagnosticIndent = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (inDiagnostic) {
const indent = rawLine.match(/^\s*/)?.[0].length || 0;
if (/^\s+\.\.\.\s*$/.test(rawLine) && indent > diagnosticOwnerIndent) {
inDiagnostic = false;
diagnosticOwnerIndent = -1;
continue;
}
const boundary = line.match(
/^(?:(?:ok|not ok) \d+ - |# (?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms) |1\.\.\d+$)/,
);
if (!boundary || indent > diagnosticOwnerIndent) continue;
output.push('<unterminated-tap-diagnostic>');
inDiagnostic = false;
diagnosticOwnerIndent = -1;
}
if (awaitingDiagnosticIndent !== null) {
if (!line) continue;
const indent = rawLine.match(/^\s*/)?.[0].length || 0;
if (/^\s+---\s*$/.test(rawLine) && indent > awaitingDiagnosticIndent) {
inDiagnostic = true;
diagnosticOwnerIndent = awaitingDiagnosticIndent;
awaitingDiagnosticIndent = null;
continue;
}
awaitingDiagnosticIndent = null;
}
// Node's test runner emits YAML diagnostics after both ok and not ok.
// Treat both as attached TAP so volatile duration_ms values never look like
// added runner noise between baseline and candidate runs.
const tapResult = rawLine.match(/^(\s*)(?:ok|not ok) \d+ - /);
if (tapResult) {
awaitingDiagnosticIndent = tapResult[1].length;
continue;
}
if (
!line ||
/^TAP version \d+$/.test(line) ||
/^ok \d+ - /.test(line) ||
/^# (?:Subtest:|tests |suites |pass |fail |cancelled |skipped |todo |duration_ms )/.test(line) ||
VOLATILE_TAP_DIAG_TAG_RE.test(line) ||
/^1\.\.\d+$/.test(line)
) {
continue;
}
output.push(
normalizeNonTapLine(
line
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, '<timestamp>')
.replace(/(?:\/private)?\/var\/folders\/\S+|\/tmp\/\S+/g, '<tmp-path>')
.replace(/:\d+:\d+\b/g, ':<line>:<column>'),
),
);
}
if (inDiagnostic) output.push('<unterminated-tap-diagnostic>');
return output;
}
function parseTapResult(text, exitCode) {
const normalized = String(text || '').replace(ANSI_RE, '').replace(/\r\n?/g, '\n');
const lines = normalized.split('\n');
const failures = [];
const failureRecords = [];
const successes = [];
let failCount = null;
let cancelledCount = null;
let skippedCount = null;
let todoCount = null;
let testCount = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const succeeded = line.match(/^\s*ok \d+ - (.+)$/);
const tapDirective = /\s+#\s*(?:SKIP|TODO)\b/i;
if (succeeded && !tapDirective.test(line)) {
successes.push(succeeded[1].trim());
}
const failed = line.match(/^\s*not ok \d+ - (.+)$/);
if (failed && !tapDirective.test(line)) {
const name = failed[1].trim();
const diagnostic = [];
const errorDetails = [];
let inDiagnostic = false;
let errorBlockIndent = -1;
let skippingStack = false;
let stackIndent = -1;
for (let detailIndex = index + 1; detailIndex < lines.length; detailIndex += 1) {
const detail = lines[detailIndex];
if (/^\s*(?:ok|not ok) \d+ - /.test(detail) || /^\s*# (?:tests|fail|cancelled|skipped|todo) \d+\s*$/.test(detail)) {
break;
}
if (/^\s*---\s*$/.test(detail)) {
inDiagnostic = true;
continue;
}
if (inDiagnostic && /^\s*\.\.\.\s*$/.test(detail)) break;
if (!inDiagnostic) continue;
const indent = detail.match(/^\s*/)?.[0].length || 0;
if (errorBlockIndent >= 0) {
if (!detail.trim()) continue;
if (indent > errorBlockIndent) {
errorDetails.push(detail.trim());
continue;
}
errorBlockIndent = -1;
}
if (skippingStack) {
if (!detail.trim() || indent > stackIndent) continue;
skippingStack = false;
}
if (/^\s*stack:\s*(?:\|-)?\s*$/.test(detail)) {
skippingStack = true;
stackIndent = indent;
continue;
}
if (/^\s*duration_ms:/.test(detail)) continue;
if (/^\s*(?:actual|expected):/.test(detail)) continue;
if (/^\s*error:\s*(?:\|-|>)\s*$/.test(detail)) {
diagnostic.push('error:');
errorBlockIndent = indent;
continue;
}
if (detail.trim()) {
diagnostic.push(normalizeStableDiagnosticDetail(detail.trimEnd()));
}
}
const assertionFailure = diagnostic.some((detail) =>
/^\s*code:\s*['"]?ERR_ASSERTION['"]?\s*$/.test(detail),
);
const stableErrorDetails = assertionFailure
? errorDetails.map(normalizeStableDiagnosticDetail)
: errorDetails.map(normalizeRuntimeDetail);
diagnostic.push(...stableErrorDetails.map((detail) => `error-detail: ${detail}`));
failures.push(name);
failureRecords.push({
name,
identity: diagnostic.length ? `${name}\n${diagnostic.join('\n')}` : name,
});
}
const failSummary = line.match(/^\s*# fail (\d+)\s*$/);
if (failSummary) {
failCount = Number(failSummary[1]);
}
const cancelledSummary = line.match(/^\s*# cancelled (\d+)\s*$/);
if (cancelledSummary) {
cancelledCount = Number(cancelledSummary[1]);
}
const skippedSummary = line.match(/^\s*# skipped (\d+)\s*$/);
if (skippedSummary) {
skippedCount = Number(skippedSummary[1]);
}
const todoSummary = line.match(/^\s*# todo (\d+)\s*$/);
if (todoSummary) {
todoCount = Number(todoSummary[1]);
}
const testSummary = line.match(/^\s*# tests (\d+)\s*$/);
if (testSummary) {
testCount = Number(testSummary[1]);
}
}
return {
exitCode: Number(exitCode),
failures,
failureRecords,
successes,
failCount,
cancelledCount,
skippedCount,
todoCount,
testCount,
nonTapOutput: collectNonTapOutput(lines),
complete:
failCount !== null &&
cancelledCount !== null &&
skippedCount !== null &&
todoCount !== null &&
testCount !== null,
};
}
function countFailures(failures) {
const counts = new Map();
for (const failure of failures) {
counts.set(failure, (counts.get(failure) || 0) + 1);
}
return counts;
}
function compareTapResults(baseline, candidate) {
const baselineSuccessCounts = countFailures(baseline.successes);
const candidateSuccessCounts = countFailures(candidate.successes);
const candidateSuccessPool = new Map(candidateSuccessCounts);
const missingBaselineSuccesses = [];
for (const [success, count] of baselineSuccessCounts) {
const available = candidateSuccessPool.get(success) || 0;
const missing = count - available;
candidateSuccessPool.set(success, Math.max(0, available - count));
for (let i = 0; i < missing; i += 1) missingBaselineSuccesses.push(success);
}
const candidateFailurePool = countFailures(
candidate.failureRecords.map((failure) => failure.identity),
);
const missingBaselineFailures = [];
for (const failure of baseline.failureRecords) {
const matchingFailures = candidateFailurePool.get(failure.identity) || 0;
if (matchingFailures > 0) {
candidateFailurePool.set(failure.identity, matchingFailures - 1);
continue;
}
const matchingSuccesses = candidateSuccessPool.get(failure.name) || 0;
if (matchingSuccesses > 0) {
candidateSuccessPool.set(failure.name, matchingSuccesses - 1);
continue;
}
missingBaselineFailures.push(failure.name);
}
const result = ({
passed,
kind,
newFailures = [],
candidateFailures = candidate.exitCode === 0 ? [] : candidate.failures,
}) => ({
passed,
kind,
baselineFailures: baseline.failures,
candidateFailures,
newFailures,
missingBaselineSuccesses,
missingBaselineFailures,
});
if (candidate.exitCode === 0) {
// Require every baseline failure title to reappear as a same-named success
// before accepting a red-to-green transition. Aggregate counts alone would allow
// deleting the failing test and adding an unrelated passer.
const baselineFailCounts = countFailures(baseline.failures);
const baselineSuccessCountsForFix = countFailures(baseline.successes);
const candidateSuccessCountsForFix = countFailures(candidate.successes);
const unresolvedBaselineFailures = [];
for (const [name, failCount] of baselineFailCounts) {
const required =
failCount + (baselineSuccessCountsForFix.get(name) || 0);
const available = candidateSuccessCountsForFix.get(name) || 0;
for (let i = 0; i < required - available; i += 1) {
unresolvedBaselineFailures.push(name);
}
}
// A fully green candidate may rename success titles when quantitative
// coverage does not shrink, but only against a complete baseline summary.
// An incomplete baseline cannot prove coverage was preserved, so fail closed.
const quantitativeClean =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
unresolvedBaselineFailures.length === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount >= baseline.testCount;
if (quantitativeClean) {
return result({ passed: true, kind: 'clean' });
}
const coverageShrunk =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount < baseline.testCount &&
missingBaselineSuccesses.length > 0;
if (coverageShrunk) {
return result({
passed: false,
kind: 'missing_baseline_successes',
newFailures: missingBaselineSuccesses,
});
}
return result({
passed: false,
kind: 'unclassified_failure',
newFailures: unresolvedBaselineFailures.length
? unresolvedBaselineFailures
: missingBaselineSuccesses,
});
}
if (baseline.exitCode === 0) {
return result({
passed: false,
kind: 'new_failures',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (!baseline.complete || !candidate.complete) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (missingBaselineSuccesses.length) {
return result({
passed: false,
kind: 'missing_baseline_successes',
candidateFailures: candidate.failures,
newFailures: missingBaselineSuccesses,
});
}
if (candidate.exitCode !== baseline.exitCode) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
const baselineNonTapCounts = countFailures(baseline.nonTapOutput);
const candidateNonTapCounts = countFailures(candidate.nonTapOutput);
const addedNonTapOutput = [...candidateNonTapCounts].some(
([line, count]) => count > (baselineNonTapCounts.get(line) || 0),
);
if (addedNonTapOutput) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (candidate.failCount === 0 || candidate.testCount < baseline.testCount) {
return result({
passed: false,
kind: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
if (
candidate.cancelledCount > 0 ||
candidate.skippedCount > baseline.skippedCount ||
candidate.todoCount > baseline.todoCount
) {
return result({
passed: false,
kind: 'cancelled_tests',
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
}
const baselineCounts = countFailures(
baseline.failureRecords.map((failure) => failure.identity),
);
const candidateCounts = countFailures(
candidate.failureRecords.map((failure) => failure.identity),
);
const newFailures = [];
for (const [identity, count] of candidateCounts) {
const extra = count - (baselineCounts.get(identity) || 0);
const name = candidate.failureRecords.find(
(failure) => failure.identity === identity,
)?.name || identity;
for (let i = 0; i < extra; i += 1) newFailures.push(name);
}
const parsedCountsMatch =
baseline.failureRecords.length >= Number(baseline.failCount) &&
candidate.failureRecords.length >= Number(candidate.failCount);
const passed =
newFailures.length === 0 &&
missingBaselineFailures.length === 0 &&
parsedCountsMatch;
const reportedFailures = newFailures.length
? newFailures
: missingBaselineFailures;
return result({
passed,
kind: passed
? 'baseline_only'
: newFailures.length
? 'new_failures'
: 'unclassified_failure',
candidateFailures: candidate.failures,
newFailures: reportedFailures,
});
}
function parseArgs(argv) {
const values = {};
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i];
if (!key?.startsWith('--') || argv[i + 1] === undefined) {
throw new Error('Expected --name value arguments.');
}
values[key.slice(2)] = argv[i + 1];
}
return values;
}
function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
for (const required of [
'baseline-log',
'baseline-exit',
'candidate-log',
'candidate-exit',
'output',
]) {
if (!(required in args)) throw new Error(`Missing --${required}.`);
}
const baseline = parseTapResult(
fs.readFileSync(args['baseline-log'], 'utf8'),
args['baseline-exit'],
);
const candidate = parseTapResult(
fs.readFileSync(args['candidate-log'], 'utf8'),
args['candidate-exit'],
);
const result = compareTapResults(baseline, candidate);
fs.writeFileSync(args.output, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
console.log(
result.passed
? `Test comparison accepted: ${result.kind}.`
: `Test comparison failed: ${result.kind}.`,
);
if (result.newFailures.length) {
console.error(`New failures:\n- ${result.newFailures.join('\n- ')}`);
}
if (!result.passed && result.missingBaselineSuccesses?.length) {
console.error(
`Missing baseline successes:\n- ${result.missingBaselineSuccesses.join('\n- ')}`,
);
}
return result.passed ? 0 : 1;
}
if (require.main === module) {
try {
process.exitCode = main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
}
}
module.exports = {
compareTapResults,
main,
parseTapResult,
};

View File

@@ -0,0 +1,483 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
compareTapResults,
parseTapResult,
} = require('./compare-ci-test-baseline.cjs');
const tap = ({ failures = [], successes = [], fail = failures.length, cancelled = 0, skipped = 0, todo = 0, tests = 10 } = {}) => [
'TAP version 13',
...failures.map((name, index) => `not ok ${index + 1} - ${name}`),
...successes.map((name, index) => `ok ${failures.length + index + 1} - ${name}`),
`# fail ${fail}`,
`# cancelled ${cancelled}`,
`# skipped ${skipped}`,
`# todo ${todo}`,
`# tests ${tests}`,
].join('\n');
test('accepts a clean candidate even when the base was red', () => {
const result = compareTapResults(
parseTapResult(tap({ failures: ['base failure'] }), 1),
parseTapResult(tap({ successes: ['base failure'] }), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
});
test('rejects a zero-exit candidate without a complete clean TAP summary', () => {
const result = compareTapResults(
parseTapResult(tap(), 0),
parseTapResult('custom test command completed', 0),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
});
test('accepts renaming a successful test when quantitative coverage does not shrink', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'] }), 0),
parseTapResult(tap({ successes: ['kept test', 'unrelated replacement'] }), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
const issueNumberName = compareTapResults(
parseTapResult(tap({
successes: ['accepts packages that ship upstream #6055/#5987/#6043'],
}), 0),
parseTapResult(tap({
successes: ['accepts packages that ship upstream #9999'],
}), 0),
);
assert.equal(issueNumberName.passed, true);
assert.equal(issueNumberName.kind, 'clean');
// Clean candidate that fixes a prior red must keep the failing test title.
const fixedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['broken test', 'other test'] }), 0),
);
assert.equal(fixedFailure.passed, true);
assert.equal(fixedFailure.kind, 'clean');
// Deleting the failing test and adding an unrelated passer is not a clean fix.
const deletedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['other test', 'unrelated replacement'] }), 0),
);
assert.equal(deletedFailure.passed, false);
assert.equal(deletedFailure.kind, 'unclassified_failure');
assert.deepEqual(deletedFailure.missingBaselineFailures, ['broken test']);
});
test('accepts a green candidate that renames one success while adding another', () => {
const result = compareTapResults(
parseTapResult(tap({
successes: ['alpha', 'beta', 'gamma'],
tests: 8886,
}), 0),
parseTapResult(tap({
successes: ['alpha', 'beta', 'delta', 'epsilon'],
tests: 8887,
}), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['gamma']);
});
test('rejects deleting successful coverage even when the run stays green', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'], tests: 12 }), 0),
parseTapResult(tap({ successes: ['kept test'], tests: 11 }), 0),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'missing_baseline_successes');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
assert.deepEqual(result.newFailures, ['removed test']);
});
test('rejects a clean candidate when the exact-base TAP summary is incomplete (fail closed)', () => {
const result = compareTapResults(
parseTapResult('base runner stopped early', 1),
parseTapResult(tap(), 0),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
});
test('accepts only failures already present on the exact base', () => {
const result = compareTapResults(
parseTapResult(tap({ failures: ['base A', 'base B'] }), 1),
parseTapResult(tap({ failures: ['base B'], successes: ['base A'] }), 1),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'baseline_only');
assert.deepEqual(result.newFailures, []);
const removedFailure = compareTapResults(
parseTapResult(tap({ failures: ['base A', 'base B'] }), 1),
parseTapResult(tap({
failures: ['base B'],
successes: ['unrelated replacement'],
}), 1),
);
assert.equal(removedFailure.passed, false);
assert.equal(removedFailure.kind, 'unclassified_failure');
});
test('rejects a different candidate failure even when both runs are red', () => {
const result = compareTapResults(
parseTapResult(tap({ failures: ['base failure'] }), 1),
parseTapResult(tap({ failures: ['candidate regression'] }), 1),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'new_failures');
assert.deepEqual(result.newFailures, ['candidate regression']);
});
test('rejects an additional runner failure after comparable TAP output', () => {
const sameTap = tap({ failures: ['base failure'] });
const differentExit = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`${sameTap}\nrunner crashed after tests`, 2),
);
assert.equal(differentExit.passed, false);
assert.equal(differentExit.kind, 'unclassified_failure');
const sameExit = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`${sameTap}\nrunner crashed after tests`, 1),
);
assert.equal(sameExit.passed, false);
assert.equal(sameExit.kind, 'unclassified_failure');
const preSummaryCrash = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`runner crashed before summary\n${sameTap}`, 1),
);
assert.equal(preSummaryCrash.passed, false);
assert.equal(preSummaryCrash.kind, 'unclassified_failure');
const crashWithoutFailureKeyword = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`${sameTap}\nSegmentation fault (core dumped)`, 1),
);
assert.equal(crashWithoutFailureKeyword.passed, false);
assert.equal(crashWithoutFailureKeyword.kind, 'unclassified_failure');
const crashInsideUnattachedYamlMarkers = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`${sameTap}\n---\nSegmentation fault (core dumped)\n...`, 1),
);
assert.equal(crashInsideUnattachedYamlMarkers.passed, false);
assert.equal(crashInsideUnattachedYamlMarkers.kind, 'unclassified_failure');
const diagnosticTap = (closed) => [
'TAP version 13',
'not ok 1 - base failure',
' ---',
" error: 'existing failure'",
" code: 'ERR_TEST_FAILURE'",
...(closed ? [' ...'] : []),
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
const crashAfterUnterminatedDiagnostic = compareTapResults(
parseTapResult(diagnosticTap(true), 1),
parseTapResult(`${diagnosticTap(false)}\nSegmentation fault (core dumped)`, 1),
);
assert.equal(crashAfterUnterminatedDiagnostic.passed, false);
assert.equal(crashAfterUnterminatedDiagnostic.kind, 'unclassified_failure');
const diagnosticWithExtra = (extra) => [
'TAP version 13',
'not ok 1 - base failure',
' ---',
" error: 'existing failure'",
" code: 'ERR_TEST_FAILURE'",
...(extra ? [` ${extra}`] : []),
' ...',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
for (const extra of ['Segmentation fault (core dumped)', 'signal: SIGSEGV']) {
const crashInsideClosedDiagnostic = compareTapResults(
parseTapResult(diagnosticWithExtra(''), 1),
parseTapResult(diagnosticWithExtra(extra), 1),
);
assert.equal(crashInsideClosedDiagnostic.passed, false);
assert.equal(crashInsideClosedDiagnostic.kind, 'new_failures');
}
const unchangedArbitraryOutput = compareTapResults(
parseTapResult(`starting custom runner\n${sameTap}`, 1),
parseTapResult(`starting custom runner\n${sameTap}`, 1),
);
assert.equal(unchangedArbitraryOutput.passed, true);
assert.equal(unchangedArbitraryOutput.kind, 'baseline_only');
});
test('distinguishes same-title failures by stable TAP diagnostics', () => {
const withDiagnostic = (error, location = '/workspace/example.test.js:10:1') => [
'TAP version 13',
'not ok 1 - duplicate title',
' ---',
` location: '${location}'`,
" failureType: 'testCodeFailure'",
` error: '${error}'`,
" code: 'ERR_ASSERTION'",
' stack: |- ',
' volatile stack line',
' ...',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
const same = compareTapResults(
parseTapResult(withDiagnostic('old failure'), 1),
parseTapResult(withDiagnostic('old failure'), 1),
);
assert.equal(same.passed, true);
const moved = compareTapResults(
parseTapResult(withDiagnostic('old failure'), 1),
parseTapResult(withDiagnostic('old failure', '/workspace/example.test.js:11:1'), 1),
);
assert.equal(moved.passed, true);
const changed = compareTapResults(
parseTapResult(withDiagnostic('old failure'), 1),
parseTapResult(withDiagnostic('new regression'), 1),
);
assert.equal(changed.passed, false);
assert.deepEqual(changed.newFailures, ['duplicate title']);
const dynamicValues = (actual, nextTitle) => [
'TAP version 13',
'not ok 1 - duplicate title',
' ---',
" location: '/workspace/example.test.js:10:1'",
" failureType: 'testCodeFailure'",
" error: 'old failure'",
` actual: ${actual}`,
' expected: 1',
" code: 'ERR_ASSERTION'",
' stack: |- ',
' volatile stack line',
' ...',
`# Subtest: ${nextTitle}`,
`ok 2 - ${nextTitle}`,
'1..2',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 2',
].join('\n');
const dynamic = compareTapResults(
parseTapResult(dynamicValues(41831, 'later test'), 1),
parseTapResult(dynamicValues(52942, 'later test'), 1),
);
assert.equal(dynamic.passed, true);
const multilineError = (reason) => [
'TAP version 13',
'not ok 1 - duplicate title',
' ---',
" location: '/workspace/example.test.js:10:1'",
" failureType: 'testCodeFailure'",
' error: |-',
` ${reason}`,
" code: 'ERR_ASSERTION'",
" operator: 'strictEqual'",
' ...',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
const changedMultilineError = compareTapResults(
parseTapResult(multilineError('old failure'), 1),
parseTapResult(multilineError('new regression'), 1),
);
assert.equal(changedMultilineError.passed, false);
assert.deepEqual(changedMultilineError.newFailures, ['duplicate title']);
const assertionDiff = (field, actual) => [
'TAP version 13',
'not ok 1 - duplicate title',
' ---',
" failureType: 'testCodeFailure'",
' error: |-',
' Expected values to be strictly equal:',
'',
' {',
` ${field}: ${actual}`,
' }',
" code: 'ERR_ASSERTION'",
" operator: 'strictEqual'",
' ...',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
const changedAssertionValue = compareTapResults(
parseTapResult(assertionDiff('now', 111), 1),
parseTapResult(assertionDiff('now', 222), 1),
);
assert.equal(changedAssertionValue.passed, true);
const changedStableAssertionValue = compareTapResults(
parseTapResult(assertionDiff('x', 1), 1),
parseTapResult(assertionDiff('x', 3), 1),
);
assert.equal(changedStableAssertionValue.passed, false);
const changedPortAssertion = compareTapResults(
parseTapResult(assertionDiff('port', 2222), 1),
parseTapResult(assertionDiff('port', 443), 1),
);
assert.equal(changedPortAssertion.passed, false);
const customMultilineError = (reason) => multilineError(reason).replace(
"code: 'ERR_ASSERTION'",
"code: 'ERR_CUSTOM'",
);
const changedCustomError = compareTapResults(
parseTapResult(customMultilineError('service rejected: old reason'), 1),
parseTapResult(customMultilineError('service rejected: new reason'), 1),
);
assert.equal(changedCustomError.passed, false);
const sameCustomErrorAcrossRuns = compareTapResults(
parseTapResult(customMultilineError(
'service failed at /tmp/run-A/result on 2026-07-31T10:00:00Z in worker.js:10:2',
), 1),
parseTapResult(customMultilineError(
'service failed at /tmp/run-B/result on 2026-07-31T10:01:00Z in worker.js:11:3',
), 1),
);
assert.equal(sameCustomErrorAcrossRuns.passed, true);
});
test('rejects added duplicate failures, cancellations, skipped tests, and TODOs', () => {
const duplicate = compareTapResults(
parseTapResult(tap({ failures: ['same'] }), 1),
parseTapResult(tap({ failures: ['same', 'same'] }), 1),
);
assert.equal(duplicate.passed, false);
const cancelled = compareTapResults(
parseTapResult(tap({ failures: ['same'] }), 1),
parseTapResult(tap({ failures: ['same'], cancelled: 2 }), 1),
);
assert.equal(cancelled.passed, false);
assert.equal(cancelled.kind, 'cancelled_tests');
const skipped = compareTapResults(
parseTapResult(tap({ skipped: 1 }), 0),
parseTapResult(tap({ skipped: 10 }), 0),
);
assert.equal(skipped.passed, false);
assert.equal(skipped.kind, 'unclassified_failure');
const todo = compareTapResults(
parseTapResult(tap({ todo: 1 }), 0),
parseTapResult(tap({ todo: 10 }), 0),
);
assert.equal(todo.passed, false);
assert.equal(todo.kind, 'unclassified_failure');
});
test('fails closed when a red run has no complete TAP summary', () => {
const result = compareTapResults(
parseTapResult('runner stopped early', 1),
parseTapResult('runner stopped early', 1),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
});
test('rejects non-test failures and a candidate that runs fewer tests', () => {
const posttestFailure = compareTapResults(
parseTapResult(tap({ failures: ['existing'] }), 1),
parseTapResult(tap({ fail: 0 }), 1),
);
assert.equal(posttestFailure.passed, false);
assert.equal(posttestFailure.kind, 'unclassified_failure');
const fewerTests = compareTapResults(
parseTapResult(tap({ failures: ['existing'], tests: 20 }), 1),
parseTapResult(tap({ failures: ['existing'], tests: 19 }), 1),
);
assert.equal(fewerTests.passed, false);
assert.equal(fewerTests.kind, 'unclassified_failure');
});
test('ignores success-case TAP YAML diagnostics and volatile console noise', () => {
const withOkDiagnostic = (durationMs, pid, sftpId, requestId, port, mtime) => [
'TAP version 13',
`# (node:${pid}) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time`,
`# sftpId: '${sftpId}',`,
`# requestId: '${requestId}',`,
`# OAuth callback server listening on http://127.0.0.1:${port}/oauth/callback`,
`# [transferDiag] {"event":"progress","id":"${requestId}","pct":1}`,
`# [FileWatcher] Initial file stats: mtime=${mtime}, size=7`,
`# [SessionLogStream] Started stream for session-${mtime}-abc -> /tmp/log`,
'ok 1 - passing helper',
' ---',
` duration_ms: ${durationMs}`,
" type: 'test'",
' ...',
'not ok 2 - base failure',
' ---',
" error: 'existing failure'",
" code: 'ERR_TEST_FAILURE'",
' ...',
'# fail 1',
'# cancelled 0',
'# skipped 0',
'# todo 0',
'# tests 10',
].join('\n');
const result = compareTapResults(
parseTapResult(
withOkDiagnostic(1.25, 9089, 'sftp-111261', 'ssh-6239b197-6933-41e3-845b-396a42d6c66b', 37289, 1786091556617.3306),
1,
),
parseTapResult(
withOkDiagnostic(9.88, 29926, 'sftp-573391', 'ssh-de14337d-3088-463a-a2ca-ffc93d609e74', 39199, 1786091557152.3345),
1,
),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'baseline_only');
assert.deepEqual(result.newFailures, []);
});
test('still rejects new bracket-prefixed runner comments outside transfer diagnostics', () => {
const sameTap = tap({ failures: ['base failure'] });
const result = compareTapResults(
parseTapResult(sameTap, 1),
parseTapResult(`${sameTap}\n# [fatal] worker aborted after suite`, 1),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
});

16
scripts/copy-monaco.cjs Normal file
View File

@@ -0,0 +1,16 @@
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const source = path.join(repoRoot, 'node_modules', 'monaco-editor', 'min', 'vs');
const target = path.join(repoRoot, 'public', 'monaco', 'vs');
if (!fs.existsSync(source)) {
console.error('[copy-monaco] Source not found:', source);
process.exit(1);
}
fs.rmSync(target, { recursive: true, force: true });
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.cpSync(source, target, { recursive: true });
console.log('[copy-monaco] Copied Monaco VS assets to', target);

View File

@@ -0,0 +1,374 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
const path = require("node:path");
const config = require("../electron-builder.config.cjs");
test("unpacked MCP server includes its shared CommonJS dependencies", () => {
assert.ok(
config.asarUnpack.includes("electron/mcp/**/*"),
"MCP server must stay unpacked so Codex can launch it as a child process",
);
assert.ok(
config.asarUnpack.includes("lib/**/*.cjs"),
"MCP server requires ../../lib/commandBlocklist.cjs from the unpacked runtime path",
);
assert.ok(
config.asarUnpack.includes("lib/**/*.json"),
"unpacked lib CommonJS modules require sibling JSON data files at runtime",
);
});
test("build.files includes shared terminal flow constants for main process", () => {
assert.ok(
config.files.includes("infrastructure/config/terminalFlowConstants.cjs"),
"terminalFlowAck.cjs requires infrastructure/config/terminalFlowConstants.cjs at packaged startup",
);
assert.ok(
config.files.includes("infrastructure/config/terminalFlowConstants.json"),
"terminalFlowConstants.cjs requires sibling terminalFlowConstants.json at packaged startup",
);
});
test("build.files includes the prompt classifier required by Mosh", () => {
assert.ok(
config.files.includes("domain/terminalPromptSecurity.shared.cjs"),
"packaged Mosh bootstrap requires the shared prompt classifier",
);
const promptSecurity = require("../domain/terminalPromptSecurity.shared.cjs");
assert.equal(promptSecurity.isUntrustedTerminalInputPrompt("验证码:"), true);
});
test("unpacked Tool CLI includes capability runtime dependencies", () => {
assert.ok(
config.asarUnpack.includes("electron/cli/**/*"),
"Tool CLI launcher and scripts must stay unpacked so agents can launch them as child processes",
);
assert.ok(
config.asarUnpack.includes("electron/capabilities/**/*"),
"Tool CLI requires capability catalog, registry, policy, timeout, and RPC transport modules from the unpacked runtime path",
);
assert.ok(
config.asarUnpack.includes("electron/shared/**/*"),
"Capability policy may load shared permission grant helpers from the unpacked runtime path",
);
});
test("build.files excludes per-platform agent binaries", () => {
const files = config.files;
const expectExclusions = [
"!**/@anthropic-ai/claude-agent-sdk-*/**/*",
"!node_modules/@anthropic-ai/claude-code-*/**/*",
"!node_modules/@openai/codex-{darwin,linux,linuxmusl,win32}-*/**/*",
"!node_modules/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**/*",
"!node_modules/@github/copilot/**/*",
"!node_modules/opencode-{darwin,linux,linuxmusl,windows}-*/**/*",
"!node_modules/opencode-ai/**/*",
];
for (const glob of expectExclusions) {
assert.ok(
files.includes(glob),
`build.files must exclude platform binary glob: ${glob}`,
);
}
});
test("asarUnpack no longer references removed legacy agent packages", () => {
const unpack = config.asarUnpack.join("\n");
for (const stale of [
"@agentclientprotocol/claude-agent-acp",
"@agentclientprotocol/sdk",
"@zed-industries/codex-acp",
]) {
assert.ok(
!unpack.includes(stale),
`asarUnpack must not reference removed package: ${stale}`,
);
}
});
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {
assert.ok(
!config.asarUnpack.includes("node_modules/@cursor/sdk/**/*"),
"Cursor SDK JavaScript can load from app.asar and should not be duplicated into app.asar.unpacked",
);
assert.ok(config.asarUnpack.includes("node_modules/@cursor/sdk-*/**/*"));
assert.ok(config.asarUnpack.includes("node_modules/sqlite3/**/*"));
});
test("beforePack installs missing Cursor SDK packages and builds Windows Hello helper", () => {
assert.equal(config.beforePack, "./scripts/beforePackCursorSdk.cjs");
});
test("Windows packaging includes the Windows Hello helper executable", () => {
assert.ok(
Array.isArray(config.win.extraResources),
"win.extraResources must be an array",
);
assert.ok(
config.win.extraResources.some((entry) => (
entry &&
entry.from === "electron/bridges/windowsHelloHelper/build/${arch}/NetcattyWindowsHello.exe" &&
entry.to === "windowsHello/NetcattyWindowsHello.exe"
)),
"Windows package must include the Windows Hello helper executable for the target arch",
);
assert.ok(
config.files.includes("!electron/bridges/windowsHelloHelper/build/**/*"),
"Windows Hello build output must not also be copied into app.asar",
);
});
test("Windows package arch is controlled by pack script CLI flags", () => {
assert.deepEqual(
config.win.target,
["nsis", "portable", "zip"],
"win.target must not hard-code x64 and arm64 or pack:win-x64 will still invoke arm64 beforePack hooks",
);
});
test("packaged app declares ssh, telnet, and jms URL protocol support", () => {
assert.deepEqual(config.protocols, [
{
name: "SSH URL",
schemes: ["ssh"],
},
{
name: "Telnet URL",
schemes: ["telnet"],
},
{
name: "JumpServer URL",
schemes: ["jms"],
},
]);
});
test("build.files trims release-only dependency payloads", () => {
const files = config.files;
for (const glob of [
"!node_modules/@cursor/sdk/dist/cjs/**/*",
"!node_modules/@cursor/sdk/dist/**/*.d.ts",
"!node_modules/@cursor/sdk/dist/**/*.d.ts.map",
"!node_modules/sqlite3/deps/**/*",
"!node_modules/**/docs/**/*",
"!node_modules/**/doc/**/*",
"!node_modules/**/benchmark/**/*",
"!node_modules/**/benchmarks/**/*",
]) {
assert.ok(files.includes(glob), `build.files must exclude release-only payload: ${glob}`);
}
});
test("build.files excludes Vite-bundled renderer-only packages", () => {
const files = config.files;
for (const glob of [
"!node_modules/react/**/*",
"!node_modules/react-dom/**/*",
"!node_modules/@radix-ui/**/*",
"!node_modules/ai/**/*",
"!node_modules/@ai-sdk/**/*",
"!node_modules/@mdxeditor/**/*",
"!node_modules/streamdown/**/*",
"!node_modules/@streamdown/**/*",
"!node_modules/@tanstack/react-virtual/**/*",
"!node_modules/pinyin-pro/**/*",
"!node_modules/re2js/**/*",
"!node_modules/@eslint-community/regexpp/**/*",
"!node_modules/clsx/**/*",
"!node_modules/tailwind-merge/**/*",
"!node_modules/use-stick-to-bottom/**/*",
"!node_modules/lexical/**/*",
"!node_modules/@lexical/**/*",
"!node_modules/@codemirror/**/*",
"!node_modules/katex/**/*",
"!node_modules/shiki/**/*",
"!node_modules/@shiki/**/*",
]) {
assert.ok(
files.includes(glob),
`build.files must exclude Vite-bundled renderer package: ${glob}`,
);
}
});
test("KaTeX distribution keeps its license notice", () => {
const notice = readFileSync(
path.join(__dirname, "..", "public", "licenses", "KaTeX-LICENSE.txt"),
"utf8",
);
assert.match(notice, /Copyright \(c\) 2013-2020 Khan Academy and other contributors/);
});
test("linux packaging uses multi-size build/icons instead of a single 1024px override", async () => {
assert.equal(
config.linux.icon,
"icons",
"linux.icon must point at build/icons so electron-builder installs hicolor/* sizes",
);
assert.equal(config.directories.buildResources, "build");
const fs = require("node:fs");
const path = require("node:path");
const iconsDir = path.join(__dirname, "..", "build", "icons");
for (const size of [16, 32, 48, 64, 128, 256, 512]) {
const file = path.join(iconsDir, `${size}x${size}.png`);
assert.ok(fs.existsSync(file), `expected Linux icon: build/icons/${size}x${size}.png`);
}
const { convertIcon } = require("app-builder-lib/out/util/iconConverter");
const projectDir = path.join(__dirname, "..");
const buildResources = path.join(projectDir, config.directories.buildResources);
const sources = [config.linux.icon, config.mac?.icon ?? config.icon].filter(Boolean);
const result = await convertIcon({
sources,
fallbackSources: [buildResources],
roots: [buildResources, projectDir],
format: "set",
outDir: path.join(projectDir, "release", ".icon-config-test"),
});
const sizes = result.icons.map((icon) => icon.size);
assert.ok(
sizes.includes(48) && sizes.includes(256) && !sizes.every((size) => size === 1024),
`expected standard hicolor sizes, got: ${sizes.join(", ")}`,
);
});
test("linux packaging includes an Arch Linux pacman package target", () => {
assert.deepEqual(
config.linux.target,
["AppImage", "deb", "rpm", "pacman"],
"linux package builds must publish AppImage, Debian, RPM, and Arch pacman artifacts",
);
});
test("rpm packaging disables generated build-id symlinks", () => {
assert.deepEqual(
config.rpm?.fpm,
[
"--rpm-rpmbuild-define",
"_build_id_links none",
"--rpm-rpmbuild-define",
"__os_install_post %{nil}",
],
"RPM packages must skip build-id links and host brp post scripts on RHEL builders",
);
});
test("rpm packaging uses gzip compression for RHEL-family package hosts", () => {
// Default electron-builder/fpm RPM compression is xzmt. AlmaLinux/RHEL 8 CI
// images provide `xz` but not the `xzmt` shim, which makes rpmbuild exit 127.
assert.equal(
config.rpm?.compression,
"gzip",
"RPM compression must avoid xzmt on RHEL 8 / AlmaLinux 8 package builders",
);
});
test("Windows package arch is controlled by pack script CLI flags", () => {
assert.deepEqual(
config.win.target,
["nsis", "portable", "zip"],
"win.target must not hard-code x64 and arm64 or pack:win-x64 will still emit broken arm64 installers",
);
});
test("windows packaging includes a zip archive target", () => {
assert.ok(
config.win.target.includes("zip"),
"windows package builds must publish a zip archive for no-install environments",
);
});
test("windows installer registers and removes Explorer folder context menu entries", () => {
const fs = require("node:fs");
const path = require("node:path");
assert.equal(
config.nsis.include,
"build/installer.nsh",
"NSIS packaging must include the Explorer context-menu installer hooks",
);
const installerScript = fs.readFileSync(
path.join(__dirname, "..", config.nsis.include),
"utf8",
);
const folderKey = String.raw`Software\Classes\Directory\shell\Netcatty`;
const backgroundKey = String.raw`Software\Classes\Directory\Background\shell\Netcatty`;
assert.match(installerScript, /!macro customInstall\b/);
assert.match(installerScript, new RegExp(`WriteRegStr SHCTX "${folderKey.replaceAll("\\", "\\\\")}"`));
assert.match(installerScript, new RegExp(`WriteRegStr SHCTX "${backgroundKey.replaceAll("\\", "\\\\")}"`));
assert.match(installerScript, /-- --open-terminal-path="%1\."/);
assert.match(installerScript, /-- --open-terminal-path="%V\."/);
assert.match(installerScript, /!macro customUnInstall\b/);
assert.match(installerScript, new RegExp(`DeleteRegKey SHCTX "${folderKey.replaceAll("\\", "\\\\")}"`));
assert.match(installerScript, new RegExp(`DeleteRegKey SHCTX "${backgroundKey.replaceAll("\\", "\\\\")}"`));
});
test("windows zip follows the requested build architecture", () => {
const { Arch } = require("builder-util");
const { Platform } = require("app-builder-lib");
const { computeArchToTargetNamesMap } = require("app-builder-lib/out/targets/targetFactory");
const rawCliTargets = new Map([[Arch.x64, []]]);
const targetsByArch = computeArchToTargetNamesMap(
rawCliTargets,
{
platformSpecificBuildOptions: config.win,
defaultTarget: ["nsis"],
},
Platform.WINDOWS,
);
assert.deepEqual(
targetsByArch.get(Arch.x64)?.slice().sort(),
["nsis", "portable", "zip"].sort(),
"pack:win-x64 must publish x64 nsis, portable, and zip",
);
assert.equal(
targetsByArch.has(Arch.arm64),
false,
"pack:win-x64 must not publish arm64 nsis/portable/zip without a dedicated arm64 job",
);
});
test("linux FPM packages use the custom post-install template", () => {
const fs = require("node:fs");
const path = require("node:path");
for (const [target, afterInstall] of Object.entries({
deb: config.deb.afterInstall,
rpm: config.rpm.afterInstall,
pacman: config.pacman.afterInstall,
})) {
assert.equal(
afterInstall,
"scripts/linux/after-install.tpl",
`${target}.afterInstall must point at the custom FPM post-install template`,
);
}
assert.equal(
config.pacman.afterRemove,
"scripts/linux/after-remove.tpl",
"pacman.afterRemove must point at the custom FPM post-remove template",
);
for (const relPath of [config.deb.afterInstall, config.pacman.afterRemove]) {
const file = path.join(__dirname, "..", relPath);
const contents = fs.readFileSync(file, "utf8");
assert.match(
contents,
/gtk-update-icon-cache.*\/usr\/share\/icons\/hicolor/,
`${relPath} must refresh the hicolor icon cache`,
);
}
});

View File

@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { createRequire } = require('node:module');
const path = require('node:path');
const vm = require('node:vm');
test('macOS signing separates temporary keychain and certificate passwords', async () => {
const modulePath = require.resolve('app-builder-lib/out/codeSign/macCodeSign.js');
const localRequire = createRequire(modulePath);
const calls = [];
const sandbox = {
exports: {},
__dirname: path.dirname(modulePath),
process: { env: { TRAVIS: 'true' } },
require(id) {
if (id === 'builder-util') return {
exec: async (file, args) => { assert.equal(file, '/usr/bin/security'); calls.push(args); return ''; },
};
if (id === './codesign') return { importCertificate: async (file) => file };
return localRequire(id);
},
};
vm.runInNewContext(readFileSync(modulePath, 'utf8'), sandbox, { filename: modulePath });
await sandbox.exports.createKeychain({
tmpDir: {}, currentDir: '/signing-test', cscLink: '/app.p12', cscKeyPassword: 'application-password',
cscILink: '/installer.p12', cscIKeyPassword: 'installer-password',
});
const flag = (args, name) => args[args.indexOf(name) + 1];
const keychainPassword = flag(calls.find((args) => args[0] === 'create-keychain'), '-p');
assert.equal(flag(calls.find((args) => args[0] === 'unlock-keychain'), '-p'), keychainPassword);
const partitions = calls.filter((args) => args[0] === 'set-key-partition-list');
assert.equal(partitions.length, 2);
for (const args of partitions) assert.equal(flag(args, '-k'), keychainPassword);
assert.deepEqual(calls.filter((args) => args[0] === 'import').map((args) => flag(args, '-P')), [
'application-password', 'installer-password',
]);
});

View File

@@ -0,0 +1,41 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createRequire } = require("node:module");
test("electron-builder classifies Fetch API 5xx responses as retryable", async () => {
const modulePath = require.resolve("app-builder-lib/out/util/electronGet.js");
const localRequire = createRequire(modulePath);
const runtime = localRequire("builder-util-runtime");
const retryDescriptor = Object.getOwnPropertyDescriptor(runtime, "retry");
let retryOptions;
Object.defineProperty(runtime, "retry", {
configurable: true,
value: async (_task, options) => {
retryOptions = options;
throw new Error("retry-probe");
},
});
delete require.cache[modulePath];
try {
const electronGet = require(modulePath);
await assert.rejects(
electronGet.downloadElectronArtifactZip({
version: "99.99.99",
platformName: "darwin",
arch: "x64",
artifactName: "electron-retry-probe",
}),
/retry-probe/,
);
assert.equal(retryOptions.shouldRetry({ response: { status: 504 } }), true);
assert.equal(retryOptions.shouldRetry({ response: { statusCode: 503 } }), true);
assert.equal(retryOptions.shouldRetry({ response: { status: 404 } }), false);
assert.equal(retryOptions.shouldRetry({ code: "ECONNRESET" }), true);
} finally {
Object.defineProperty(runtime, "retry", retryDescriptor);
delete require.cache[modulePath];
}
});

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 <prepare|verify> <x64|arm64>" >&2
exit 1
}
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
electron_bin() {
echo "./node_modules/.bin/electron"
}
log_file_info() {
local file="$1"
echo "[node-pty] file: ${file}"
ls -lh "${file}"
checksum "${file}"
}
log_optional_spawn_helper() {
local file="$1"
if [[ -f "${file}" ]]; then
test -x "${file}"
log_file_info "${file}"
else
echo "[node-pty] spawn-helper not present at ${file} (expected on Linux)"
fi
}
log_electron_runtime_info() {
ELECTRON_RUN_AS_NODE=1 "$(electron_bin)" -e '
console.log(`[node-pty] electron=${process.versions.electron || "unknown"} node=${process.versions.node} modules=${process.versions.modules}`);
'
}
assert_loadable_native_module() {
local file="$1"
echo "[node-pty] loading native module with Electron runtime: ${file}"
ELECTRON_RUN_AS_NODE=1 "$(electron_bin)" -e '
const path = require("node:path");
require(path.resolve(process.argv[1]));
console.log("[node-pty] native module loaded successfully");
' "${file}"
}
resolve_serialport_prebuild() {
local root="$1"
local arch="$2"
local file
file="$(find "${root}/prebuilds/linux-${arch}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
if [[ -z "${file}" ]]; then
echo "[node-pty] serialport glibc prebuild not found for linux-${arch}" >&2
exit 1
fi
echo "${file}"
}
prepare() {
local arch="$1"
local root="node_modules/node-pty"
local release_dir="${root}/build/Release"
local prebuild_dir="${root}/prebuilds/linux-${arch}"
local serialport_root="node_modules/@serialport/bindings-cpp"
local serialport_release_dir="${serialport_root}/build/Release"
local serialport_prebuild
echo "[node-pty] rebuilding native modules for Electron on linux-${arch}"
log_electron_runtime_info
rm -rf "${release_dir}" "${prebuild_dir}" "${serialport_release_dir}"
npx electron-rebuild --force --arch "${arch}" -w "node-pty,@serialport/bindings-cpp"
test -f "${release_dir}/pty.node"
test -f "${serialport_release_dir}/bindings.node"
echo "[node-pty] built Linux runtime artifacts:"
log_file_info "${release_dir}/pty.node"
log_optional_spawn_helper "${release_dir}/spawn-helper"
assert_loadable_native_module "${release_dir}/pty.node"
log_file_info "${serialport_release_dir}/bindings.node"
assert_loadable_native_module "${serialport_release_dir}/bindings.node"
mkdir -p "${prebuild_dir}"
cp "${release_dir}/pty.node" "${prebuild_dir}/pty.node"
if [[ -f "${release_dir}/spawn-helper" ]]; then
cp "${release_dir}/spawn-helper" "${prebuild_dir}/spawn-helper"
fi
echo "[node-pty] mirrored Linux runtime artifacts into ${prebuild_dir}:"
log_file_info "${prebuild_dir}/pty.node"
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
serialport_prebuild="$(resolve_serialport_prebuild "${serialport_root}" "${arch}")"
echo "[node-pty] serialport packaged prebuild candidate:"
log_file_info "${serialport_prebuild}"
assert_loadable_native_module "${serialport_prebuild}"
}
verify() {
local arch="$1"
local release_dir
local prebuild_dir
local serialport_release_file
local serialport_prebuild_file
log_electron_runtime_info
release_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/build/Release" -print -quit)"
prebuild_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${arch}" -print -quit)"
serialport_release_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/build/Release/bindings.node" -print -quit)"
serialport_prebuild_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/prebuilds/linux-${arch}/@serialport+bindings-cpp*.glibc.node" -print | sort | head -n 1)"
if [[ -z "${release_dir}" ]]; then
echo "[node-pty] packaged build/Release directory not found under release/" >&2
exit 1
fi
if [[ -z "${prebuild_dir}" ]]; then
echo "[node-pty] packaged prebuild directory not found for linux-${arch} under release/" >&2
exit 1
fi
if [[ -z "${serialport_release_file}" ]]; then
echo "[node-pty] packaged serialport build/Release binding not found under release/" >&2
exit 1
fi
if [[ -z "${serialport_prebuild_file}" ]]; then
echo "[node-pty] packaged serialport glibc prebuild not found for linux-${arch} under release/" >&2
exit 1
fi
test -f "${release_dir}/pty.node"
test -f "${prebuild_dir}/pty.node"
echo "[node-pty] packaged build/Release artifacts:"
log_file_info "${release_dir}/pty.node"
log_optional_spawn_helper "${release_dir}/spawn-helper"
assert_loadable_native_module "${release_dir}/pty.node"
echo "[node-pty] packaged prebuild artifacts:"
log_file_info "${prebuild_dir}/pty.node"
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
assert_loadable_native_module "${prebuild_dir}/pty.node"
echo "[node-pty] packaged serialport build/Release artifact:"
log_file_info "${serialport_release_file}"
assert_loadable_native_module "${serialport_release_file}"
echo "[node-pty] packaged serialport prebuild artifact:"
log_file_info "${serialport_prebuild_file}"
assert_loadable_native_module "${serialport_prebuild_file}"
echo "[node-pty] packaged artifact locations:"
find release -path "*/resources/app.asar.unpacked/node_modules/node-pty/*" \
\( -name 'pty.node' -o -name 'spawn-helper' \) \
-print | sort
find release -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/*" \
\( -name 'bindings.node' -o -name '@serialport+bindings-cpp*.node' \) \
-print | sort
}
main() {
if [[ $# -ne 2 ]]; then
usage
fi
case "$1" in
prepare)
prepare "$2"
;;
verify)
verify "$2"
;;
*)
usage
;;
esac
}
main "$@"

View File

@@ -0,0 +1,76 @@
// Compute the platform-specific `extraResources` entry for bundling the
// EternalTerminal `et` 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-et-binaries.yml and
// downloaded into resources/et/<platform-arch>/ by
// scripts/fetch-et-binaries.cjs (gated on ET_BIN_RELEASE).
//
// We only emit the directive when the binary is actually on disk so that
// `npm run pack` keeps working without a bundled et — for example, when the
// developer skipped the fetch step or the relevant arch hasn't been built
// yet.
//
// Unlike mosh-client, `et` is a pure network-transport client and does not
// render a terminal locally, so there is no terminfo bundle to package.
const fs = require("node:fs");
const path = require("node:path");
function requestedArch() {
return process.env.npm_config_arch || process.env.npm_config_target_arch || process.arch;
}
function hasFile(file) {
return fs.existsSync(file) && fs.statSync(file).isFile();
}
function hasDir(dir) {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
}
function etExtraResources(platform) {
const etRoot = path.resolve(process.cwd(), "resources", "et");
if (!fs.existsSync(etRoot)) return [];
if (platform === "darwin") {
const file = path.join(etRoot, "darwin-universal", "et");
if (!hasFile(file)) return [];
return [
{ from: "resources/et/darwin-universal/", to: "et/", filter: ["et"] },
];
}
if (platform === "linux") {
const arch = requestedArch();
const file = path.join(etRoot, `linux-${arch}`, "et");
if (!hasFile(file)) return [];
return [
{ from: `resources/et/linux-${arch}/`, to: "et/", filter: ["et"] },
];
}
if (platform === "win32") {
const arch = requestedArch();
const exe = path.join(etRoot, `win32-${arch}`, "et.exe");
const dllDir = path.join(etRoot, `win32-${arch}`, `et-win32-${arch}-dlls`);
if (!hasFile(exe)) return [];
const resources = [
{ from: `resources/et/win32-${arch}/`, to: "et/", filter: ["et.exe"] },
];
// Static MSVC builds ship no DLLs; only package the directory when a
// dynamically-linked build produced one.
if (hasDir(dllDir)) {
resources.push({
from: `resources/et/win32-${arch}/et-win32-${arch}-dlls/`,
to: `et/et-win32-${arch}-dlls/`,
filter: ["**/*"],
});
}
return resources;
}
return [];
}
module.exports = { etExtraResources };

View File

@@ -0,0 +1,107 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { etExtraResources } = require("./et-extra-resources.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-et-resources-"));
t.after(() => {
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
function withCwdAndArch(t, cwd, arch) {
const oldCwd = process.cwd();
const oldArch = process.env.npm_config_arch;
process.chdir(cwd);
process.env.npm_config_arch = arch;
t.after(() => {
process.chdir(oldCwd);
if (oldArch === undefined) delete process.env.npm_config_arch;
else process.env.npm_config_arch = oldArch;
});
}
function writeFile(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "x");
}
test("etExtraResources returns concrete Linux arch paths", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "et", "linux-x64", "et"));
const got = etExtraResources("linux");
assert.deepEqual(got, [
{ from: "resources/et/linux-x64/", to: "et/", filter: ["et"] },
]);
});
test("etExtraResources returns concrete Linux arm64 paths", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "arm64");
writeFile(path.join(root, "resources", "et", "linux-arm64", "et"));
const got = etExtraResources("linux");
assert.deepEqual(got, [
{ from: "resources/et/linux-arm64/", to: "et/", filter: ["et"] },
]);
});
test("etExtraResources packages the universal Darwin binary", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "et", "darwin-universal", "et"));
const got = etExtraResources("darwin");
assert.deepEqual(got, [
{ from: "resources/et/darwin-universal/", to: "et/", filter: ["et"] },
]);
});
test("etExtraResources returns concrete Windows arch paths only when that arch exists", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "et", "win32-x64", "et.exe"));
const got = etExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/et/win32-x64/", to: "et/", filter: ["et.exe"] },
]);
process.env.npm_config_arch = "arm64";
assert.deepEqual(etExtraResources("win32"), []);
});
test("etExtraResources packages an optional Windows DLL directory when present", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "et", "win32-x64", "et.exe"));
writeFile(path.join(root, "resources", "et", "win32-x64", "et-win32-x64-dlls", "vcruntime140.dll"));
const got = etExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/et/win32-x64/", to: "et/", filter: ["et.exe"] },
{
from: "resources/et/win32-x64/et-win32-x64-dlls/",
to: "et/et-win32-x64-dlls/",
filter: ["**/*"],
},
]);
});
test("etExtraResources returns [] when the binary is missing", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
fs.mkdirSync(path.join(root, "resources", "et"), { recursive: true });
assert.deepEqual(etExtraResources("linux"), []);
assert.deepEqual(etExtraResources("darwin"), []);
assert.deepEqual(etExtraResources("win32"), []);
});

View File

@@ -0,0 +1,349 @@
#!/usr/bin/env node
/* eslint-disable no-console */
//
// Download platform-specific EternalTerminal `et` client binaries built by
// the `build-et-binaries` GitHub Actions workflow into resources/et/, 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 et
// (e.g. when ET_BIN_RELEASE is unset).
//
// Usage:
// node scripts/fetch-et-binaries.cjs # all platforms
// node scripts/fetch-et-binaries.cjs --platform=darwin --arch=universal
// node scripts/fetch-et-binaries.cjs --host --resolve-release
//
// Env knobs:
// ET_BIN_RELEASE — release tag in ${ET_BIN_OWNER}/${ET_BIN_REPO}.
// Skip the whole step if unset (printed as a notice so
// the build doesn't silently miss the bundling).
// ET_BIN_OWNER — defaults to the GITHUB_REPOSITORY owner, or 'binaricat'
// ET_BIN_REPO — default 'Netcatty-et-bin' (a dedicated binary
// repository so the client repo stays source-only).
// ET_BIN_BASE_URL — full override (e.g. for staging / local mirror).
// ET_BIN_RES_DIR — override output dir for tests.
// ET_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
// without SHA256SUMS. Never use for release builds.
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const https = require("node:https");
const os = require("node:os");
const crypto = require("node:crypto");
const { execFileSync } = require("node:child_process");
const { main: resolveEtBinRelease } = require("./resolve-et-bin-release.cjs");
const ROOT = path.resolve(__dirname, "..");
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "et");
// (file basename in the release -> platform-arch subdir under resources/et/)
// Using flat names in the release for SHA256SUMS readability, then fanning
// out into platform-arch subdirs locally. Every target is a tar.gz bundle
// containing the single `et` (or `et.exe`) client binary. `et` is a pure
// network-transport client, so — unlike mosh-client — there is no terminfo
// to bundle.
const TARGETS = [
{ platform: "linux", arch: "x64", file: "et-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz" },
{ platform: "linux", arch: "arm64", file: "et-linux-arm64.tar.gz", localDir: "linux-arm64", extract: "tar.gz" },
{ platform: "darwin", arch: "universal", file: "et-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz" },
{ platform: "win32", arch: "x64", file: "et-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz" },
];
function log(msg) { console.log(`[fetch-et-binaries] ${msg}`); }
function warn(msg) { console.warn(`[fetch-et-binaries] WARN ${msg}`); }
function transferFor(url) {
const protocol = new URL(url).protocol;
if (protocol === "https:") return https;
if (protocol === "http:") return http;
throw new Error(`unsupported protocol for ${url}`);
}
function follow(url, depth = 0) {
return new Promise((resolve, reject) => {
if (depth > 5) return reject(new Error("too many redirects"));
transferFor(url).get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
resolve(follow(new URL(res.headers.location, url).toString(), depth + 1));
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
}).on("error", reject);
});
}
function parseSums(text) {
const map = new Map();
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^([0-9a-f]{64})\s+\*?\s*(\S+)\s*$/i);
if (m) map.set(m[2], m[1].toLowerCase());
}
return map;
}
async function fetchSums(baseUrl, { allowUnverified = false } = {}) {
try {
const buf = await follow(`${baseUrl}/SHA256SUMS`);
return parseSums(buf.toString("utf8"));
} catch (err) {
if (allowUnverified) {
warn(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
return new Map();
}
throw new Error(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
}
}
function assertSafeTarEntry(entry) {
const name = entry.trim();
if (!name) throw new Error("tarball contains an empty entry name");
if (name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) {
throw new Error(`tarball contains an absolute path: ${name}`);
}
if (name.includes("\\")) {
throw new Error(`tarball contains a Windows-style path: ${name}`);
}
const parts = name.split("/");
if (parts.includes("..")) {
throw new Error(`tarball contains a parent-directory path: ${name}`);
}
}
function resolveTarArchiveInvocation(archivePath, platform = process.platform) {
const pathApi = platform === "win32" ? path.win32 : path;
return {
cwd: pathApi.dirname(archivePath),
archive: pathApi.basename(archivePath),
};
}
function listTarEntries(archivePath) {
const { cwd, archive } = resolveTarArchiveInvocation(archivePath);
const out = execFileSync("tar", ["-tzf", archive], { cwd, encoding: "utf8" });
return out.split(/\r?\n/).filter(Boolean);
}
function validateTarEntries(entries) {
if (entries.length === 0) throw new Error("tarball is empty");
for (const entry of entries) assertSafeTarEntry(entry);
}
function chmodExecutable(filePath) {
if (process.platform !== "win32" && fs.existsSync(filePath) && !fs.lstatSync(filePath).isSymbolicLink()) {
try { fs.chmodSync(filePath, 0o755); } catch { /* ignore */ }
}
}
function parseEtBinRepository(env) {
const githubOwner = (env.GITHUB_REPOSITORY || "").split("/")[0];
return {
owner: env.ET_BIN_OWNER || githubOwner || "binaricat",
repo: env.ET_BIN_REPO || "Netcatty-et-bin",
};
}
function resolveHostTarget(opts = {}) {
const platform = opts.platform || process.platform;
const arch = opts.arch || process.arch;
if (platform === "darwin") return { platform: "darwin", arch: "universal" };
if (platform === "linux" && (arch === "x64" || arch === "arm64")) return { platform, arch };
if (platform === "win32" && arch === "x64") return { platform, arch };
throw new Error(`No bundled et target for ${platform}-${arch}`);
}
function assertExtractedTreeSafe(root) {
const stack = [root];
while (stack.length > 0) {
const dir = stack.pop();
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name);
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink()) {
throw new Error(`tarball contains a symbolic link: ${path.relative(root, file)}`);
}
if (stat.isDirectory()) {
stack.push(file);
continue;
}
if (!stat.isFile()) {
throw new Error(`tarball contains an unsupported file type: ${path.relative(root, file)}`);
}
}
}
}
function normalizeWindowsBundle(extractDir, target) {
const genericExe = path.join(extractDir, "et.exe");
const legacyExe = path.join(extractDir, `et-${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 et.exe`);
}
// A statically-linked MSVC build ships no DLLs; the DLL directory is
// optional and only present for dynamically-linked builds.
chmodExecutable(genericExe);
}
function normalizePosixBundle(extractDir, target) {
const binary = path.join(extractDir, "et");
const legacyBinary = path.join(extractDir, `et-${target.platform}-${target.arch}`);
if (!fs.existsSync(binary) && fs.existsSync(legacyBinary)) {
fs.renameSync(legacyBinary, binary);
}
if (!fs.existsSync(binary) || !fs.lstatSync(binary).isFile()) {
throw new Error(`${target.file} did not contain et`);
}
chmodExecutable(binary);
}
function normalizeBundle(extractDir, target) {
if (target.platform === "win32") return normalizeWindowsBundle(extractDir, target);
return normalizePosixBundle(extractDir, target);
}
function replaceDir(srcDir, destDir) {
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destDir), { recursive: true });
try {
fs.renameSync(srcDir, destDir);
} catch (err) {
if (!err || err.code !== "EXDEV") throw err;
fs.cpSync(srcDir, destDir, { recursive: true });
fs.rmSync(srcDir, { recursive: true, force: true });
}
}
function unpackTarGz(buf, target, { resDir }) {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-et-"));
const archive = path.join(tmpRoot, "bundle.tar.gz");
const extractDir = path.join(tmpRoot, "extract");
const destDir = path.join(resDir, target.localDir);
fs.mkdirSync(extractDir, { recursive: true });
try {
fs.writeFileSync(archive, buf);
validateTarEntries(listTarEntries(archive));
const archiveInvocation = resolveTarArchiveInvocation(archive);
execFileSync("tar", ["-xzf", archiveInvocation.archive, "-C", path.basename(extractDir)], {
cwd: archiveInvocation.cwd,
stdio: "inherit",
});
assertExtractedTreeSafe(extractDir);
normalizeBundle(extractDir, target);
replaceDir(extractDir, destDir);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
return destDir;
}
async function fetchOne(target, sums, opts) {
const { baseUrl, resDir, allowUnverified = false } = opts;
const url = `${baseUrl}/${target.file}`;
let buf;
try {
buf = await follow(url);
} catch (err) {
throw new Error(`download failed for ${target.file}: ${err.message}`);
}
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 ${target.file}: expected ${expected}, got ${actual}`);
}
if (!expected) {
if (!allowUnverified) {
throw new Error(`no SHA256 entry for ${target.file}`);
}
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
}
const destDir = unpackTarGz(buf, target, { resDir });
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
return true;
}
async function main(argv = process.argv.slice(2), env = process.env) {
const platformArg = (argv.find((a) => a.startsWith("--platform=")) || "").split("=")[1];
const archArg = (argv.find((a) => a.startsWith("--arch=")) || "").split("=")[1];
let hostTarget = null;
if (argv.includes("--host")) {
try {
hostTarget = resolveHostTarget({ platform: platformArg || process.platform, arch: archArg || process.arch });
} catch (err) {
warn(`${err.message} - skipping host et fetch.`);
return 0;
}
}
let release = env.ET_BIN_RELEASE;
if (!release && argv.includes("--resolve-release")) {
try {
release = await resolveEtBinRelease(env);
} catch (err) {
if (argv.includes("--host")) {
warn(`could not resolve an et binary release (${err.message}) - skipping host et fetch.`);
return 0;
}
throw err;
}
}
if (!release) {
log("ET_BIN_RELEASE is unset - skipping. Set it (e.g. et-bin-6.2.10-1) to bundle et into the package.");
return 0;
}
const { owner, repo } = parseEtBinRepository(env);
const baseUrl = env.ET_BIN_BASE_URL ||
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
const resDir = path.resolve(env.ET_BIN_RES_DIR || DEFAULT_RES_DIR);
const allowUnverified = env.ET_BIN_ALLOW_UNVERIFIED === "true";
const platformFilter = hostTarget?.platform || platformArg;
const archFilter = hostTarget?.arch || archArg;
log(`release=${release} owner=${owner} repo=${repo}`);
const sums = await fetchSums(baseUrl, { allowUnverified });
let ok = 0;
let total = 0;
for (const target of TARGETS) {
if (platformFilter && target.platform !== platformFilter) continue;
if (archFilter && target.arch !== archFilter) continue;
total += 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`);
return 0;
}
if (require.main === module) {
main().catch((err) => {
console.error(`[fetch-et-binaries] FATAL ${err.message}`);
process.exit(1);
});
}
module.exports = {
TARGETS,
parseEtBinRepository,
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
parseSums,
validateTarEntries,
assertExtractedTreeSafe,
unpackTarGz,
normalizeBundle,
main,
};

View File

@@ -0,0 +1,302 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { execFile, execFileSync } = require("node:child_process");
const { promisify } = require("node:util");
const crypto = require("node:crypto");
const script = path.resolve(__dirname, "fetch-et-binaries.cjs");
const execFileAsync = promisify(execFile);
const {
parseEtBinRepository,
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
} = require("./fetch-et-binaries.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-fetch-et-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function makeTarGz(t, entries) {
const dir = makeTmp(t);
for (const [name, contents] of Object.entries(entries)) {
const file = path.join(dir, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
// Use cwd + a relative archive name so GNU tar (Git Bash on Windows) does
// not treat a "C:" drive prefix in the archive path as a remote host.
const outDir = makeTmp(t);
execFileSync("tar", ["-czf", "bundle.tar.gz", "-C", dir, "."], { cwd: outDir, stdio: "pipe" });
return fs.readFileSync(path.join(outDir, "bundle.tar.gz"));
}
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-et-binaries defaults to the dedicated et binary repository", () => {
assert.deepEqual(parseEtBinRepository({}), { owner: "binaricat", repo: "Netcatty-et-bin" });
assert.deepEqual(parseEtBinRepository({ GITHUB_REPOSITORY: "owner/project" }), {
owner: "owner",
repo: "Netcatty-et-bin",
});
assert.deepEqual(
parseEtBinRepository({ GITHUB_REPOSITORY: "owner/project", ET_BIN_OWNER: "bin", ET_BIN_REPO: "binaries" }),
{ owner: "bin", repo: "binaries" },
);
});
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" });
assert.throws(() => resolveHostTarget({ platform: "freebsd", arch: "x64" }), /No bundled et target/);
});
test("tar archive invocation uses a relative archive name for Windows paths", () => {
assert.deepEqual(
resolveTarArchiveInvocation(
"C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\netcatty-et-abc\\bundle.tar.gz",
"win32",
),
{
cwd: "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\netcatty-et-abc",
archive: "bundle.tar.gz",
},
);
});
test("replaceDir falls back to copy when rename crosses devices", (t) => {
const root = makeTmp(t);
const src = path.join(root, "src");
const dest = path.join(root, "dest");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "et.exe"), "exe");
const originalRenameSync = fs.renameSync;
fs.renameSync = (from, to) => {
if (from === src && to === dest) {
const error = new Error("cross-device link not permitted");
error.code = "EXDEV";
throw error;
}
return originalRenameSync(from, to);
};
t.after(() => {
fs.renameSync = originalRenameSync;
});
replaceDir(src, dest);
assert.equal(fs.existsSync(src), false);
assert.equal(fs.readFileSync(path.join(dest, "et.exe"), "utf8"), "exe");
});
test("fetch-et-binaries host mode skips unsupported local targets", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const baseUrl = await serveAssets(t, { SHA256SUMS: "" });
const { stderr } = await execFileAsync(
process.execPath,
[script, "--host", "--platform=win32", "--arch=arm64"],
{
env: {
...process.env,
ET_BIN_RELEASE: "test",
ET_BIN_BASE_URL: baseUrl,
ET_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
},
);
assert.match(stderr, /No bundled et target for win32-arm64/);
assert.equal(fs.existsSync(resDir), false);
});
test("fetch-et-binaries skips when ET_BIN_RELEASE is unset", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const { stdout } = await execFileAsync(process.execPath, [script], {
env: { ...process.env, ET_BIN_RELEASE: "", ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
});
assert.match(stdout, /ET_BIN_RELEASE is unset/);
assert.equal(fs.existsSync(resDir), false);
});
test("fetch-et-binaries host dev mode skips when release resolution is unavailable", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const apiBase = await serveAssets(t, {});
const { stderr } = await execFileAsync(
process.execPath,
[script, "--host", "--resolve-release", "--platform=linux", "--arch=x64"],
{
env: {
...process.env,
ET_BIN_RELEASE: "",
ET_BIN_RES_DIR: resDir,
GITHUB_API_URL: apiBase,
GITHUB_REPOSITORY: "owner/project",
CI: "true",
},
stdio: "pipe",
},
);
assert.match(stderr, /could not resolve an et binary release/i);
assert.equal(fs.existsSync(resDir), false);
});
test("fetch-et-binaries unpacks the Linux tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { et: "binary" });
const baseUrl = await serveAssets(t, {
"et-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-linux-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "et")), true);
assert.equal(fs.readFileSync(path.join(resDir, "linux-x64", "et"), "utf8"), "binary");
});
test("fetch-et-binaries unpacks the Darwin universal tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { et: "binary" });
const baseUrl = await serveAssets(t, {
"et-darwin-universal.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-darwin-universal.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=darwin", "--arch=universal"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "et")), true);
});
test("fetch-et-binaries normalizes a static Windows tarball with no DLLs", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { "et.exe": "exe" });
const baseUrl = await serveAssets(t, {
"et-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-win32-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "et.exe")), true);
});
test("fetch-et-binaries packages an optional Windows DLL directory when present", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { "et.exe": "exe", "et-win32-x64-dlls/vcruntime140.dll": "dll" });
const baseUrl = await serveAssets(t, {
"et-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-win32-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "et.exe")), true);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "et-win32-x64-dlls", "vcruntime140.dll")), true);
});
test("fetch-et-binaries rejects a tarball without et", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { "readme.txt": "nope" });
const baseUrl = await serveAssets(t, {
"et-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-linux-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
}),
/did not contain et/,
);
});
test("fetch-et-binaries fails when SHA256SUMS lacks the requested asset", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "et");
const tar = makeTarGz(t, { et: "binary" });
const baseUrl = await serveAssets(t, {
"et-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(Buffer.from("other"))} other-file\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: { ...process.env, ET_BIN_RELEASE: "test", ET_BIN_BASE_URL: baseUrl, ET_BIN_RES_DIR: resDir, CI: "true" },
stdio: "pipe",
}),
/no SHA256 entry/,
);
});
test("fetch-et-binaries rejects symlinks inside tarballs", { skip: process.platform === "win32" }, async (t) => {
const srcDir = makeTmp(t);
fs.writeFileSync(path.join(srcDir, "outside"), "outside");
fs.symlinkSync(path.join(srcDir, "outside"), path.join(srcDir, "et"));
const outDir = makeTmp(t);
execFileSync("tar", ["-czf", "symlink.tar.gz", "-C", srcDir, "et"], { cwd: outDir, stdio: "pipe" });
const tar = fs.readFileSync(path.join(outDir, "symlink.tar.gz"));
const baseUrl = await serveAssets(t, {
"et-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} et-linux-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
ET_BIN_RELEASE: "test",
ET_BIN_BASE_URL: baseUrl,
ET_BIN_RES_DIR: path.join(makeTmp(t), "resources", "et"),
CI: "true",
},
stdio: "pipe",
}),
/symbolic link|did not contain et/,
);
});

View File

@@ -0,0 +1,365 @@
#!/usr/bin/env node
/* eslint-disable no-console */
//
// 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
// node scripts/fetch-mosh-binaries.cjs --platform=darwin --arch=universal
// node scripts/fetch-mosh-binaries.cjs --host --resolve-release
//
// 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");
const http = require("node:http");
const https = require("node:https");
const os = require("node:os");
const crypto = require("node:crypto");
const { execFileSync } = require("node:child_process");
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 TARGETS = [
{
platform: "linux", arch: "x64",
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", binary: "mosh-client",
},
{
platform: "darwin", arch: "universal",
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", binary: "mosh-client.exe",
},
];
function log(msg) { console.log(`[fetch-mosh-binaries] ${msg}`); }
function warn(msg) { console.warn(`[fetch-mosh-binaries] WARN ${msg}`); }
function transferFor(url) {
const protocol = new URL(url).protocol;
if (protocol === "https:") return https;
if (protocol === "http:") return http;
throw new Error(`unsupported protocol for ${url}`);
}
function follow(url, depth = 0) {
return new Promise((resolve, reject) => {
if (depth > 5) return reject(new Error("too many redirects"));
transferFor(url).get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
resolve(follow(new URL(res.headers.location, url).toString(), depth + 1));
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
}).on("error", reject);
});
}
function parseSums(text) {
const map = new Map();
for (const line of text.split(/\r?\n/)) {
const m = line.match(/^([0-9a-f]{64})\s+\*?\s*(\S+)\s*$/i);
if (m) map.set(m[2], m[1].toLowerCase());
}
return map;
}
async function fetchSums(baseUrl, { allowUnverified = false } = {}) {
try {
const buf = await follow(`${baseUrl}/SHA256SUMS`);
return parseSums(buf.toString("utf8"));
} catch (err) {
if (allowUnverified) {
warn(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
return new Map();
}
throw new Error(`could not fetch SHA256SUMS from ${baseUrl} (${err.message})`);
}
}
function assertSafeTarEntry(entry) {
const name = entry.trim();
if (!name) throw new Error("tarball contains an empty entry name");
if (name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) {
throw new Error(`tarball contains an absolute path: ${name}`);
}
if (name.includes("\\")) {
throw new Error(`tarball contains a Windows-style path: ${name}`);
}
const parts = name.split("/");
if (parts.includes("..")) {
throw new Error(`tarball contains a parent-directory path: ${name}`);
}
}
function resolveTarArchiveInvocation(archivePath, platform = process.platform) {
const pathApi = platform === "win32" ? path.win32 : path;
return {
cwd: pathApi.dirname(archivePath),
archive: pathApi.basename(archivePath),
};
}
function listTarEntries(archivePath) {
const { cwd, archive } = resolveTarArchiveInvocation(archivePath);
const out = execFileSync("tar", ["-tzf", archive], { cwd, encoding: "utf8" });
return out.split(/\r?\n/).filter(Boolean);
}
function validateTarEntries(entries) {
if (entries.length === 0) throw new Error("tarball is empty");
for (const entry of entries) assertSafeTarEntry(entry);
}
function chmodExecutable(filePath) {
if (process.platform !== "win32" && fs.existsSync(filePath) && !fs.lstatSync(filePath).isSymbolicLink()) {
try { fs.chmodSync(filePath, 0o755); } catch { /* ignore */ }
}
}
function parseMoshBinRepository(env) {
// Canonical default binaricat/MoshCatty — never inherit fork owner from
// GITHUB_REPOSITORY (same policy as resolve-mosh-bin-release).
return {
owner: env.MOSH_BIN_OWNER || "binaricat",
repo: env.MOSH_BIN_REPO || "MoshCatty",
};
}
function resolveHostTarget(opts = {}) {
const platform = opts.platform || process.platform;
const arch = opts.arch || process.arch;
if (platform === "darwin") return { platform: "darwin", arch: "universal" };
if (platform === "linux" && (arch === "x64" || arch === "arm64")) return { platform, arch };
if (platform === "win32" && arch === "x64") return { platform, arch };
throw new Error(`No bundled mosh-client target for ${platform}-${arch}`);
}
function assertExtractedTreeSafe(root) {
const stack = [root];
while (stack.length > 0) {
const dir = stack.pop();
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name);
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink()) {
throw new Error(`tarball contains a symbolic link: ${path.relative(root, file)}`);
}
if (stat.isDirectory()) {
stack.push(file);
continue;
}
if (!stat.isFile()) {
throw new Error(`tarball contains an unsupported file type: ${path.relative(root, file)}`);
}
}
}
}
/** 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 (!found) {
throw new Error(`${target.file} did not contain ${wanted}`);
}
// 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);
for (const name of fs.readdirSync(extractDir)) {
if (name === ".moshcatty-clean") continue;
fs.rmSync(path.join(extractDir, name), { recursive: true, force: true });
}
for (const name of fs.readdirSync(cleanDir)) {
fs.renameSync(path.join(cleanDir, name), path.join(extractDir, name));
}
fs.rmSync(cleanDir, { recursive: true, force: true });
}
function replaceDir(srcDir, destDir) {
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destDir), { recursive: true });
try {
fs.renameSync(srcDir, destDir);
} catch (err) {
if (!err || err.code !== "EXDEV") throw err;
fs.cpSync(srcDir, destDir, { recursive: true });
fs.rmSync(srcDir, { recursive: true, force: true });
}
}
function unpackTarGz(buf, target, { resDir }) {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-"));
const archive = path.join(tmpRoot, "bundle.tar.gz");
const extractDir = path.join(tmpRoot, "extract");
const destDir = path.join(resDir, target.localDir);
fs.mkdirSync(extractDir, { recursive: true });
try {
fs.writeFileSync(archive, buf);
validateTarEntries(listTarEntries(archive));
const archiveInvocation = resolveTarArchiveInvocation(archive);
execFileSync("tar", ["-xzf", archiveInvocation.archive, "-C", path.basename(extractDir)], {
cwd: archiveInvocation.cwd,
stdio: "inherit",
});
assertExtractedTreeSafe(extractDir);
normalizeMoshCattyBundle(extractDir, target);
replaceDir(extractDir, destDir);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
return destDir;
}
async function fetchOne(target, sums, opts) {
const { baseUrl, resDir, allowUnverified = false } = opts;
const url = `${baseUrl}/${target.file}`;
let buf;
try {
buf = await follow(url);
} catch (err) {
throw new Error(`download failed for ${target.file}: ${err.message}`);
}
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 ${target.file}: expected ${expected}, got ${actual}`);
}
if (!expected) {
if (!allowUnverified) {
throw new Error(`no SHA256 entry for ${target.file}`);
}
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
}
const destDir = unpackTarGz(buf, target, { resDir });
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
return true;
}
async function main(argv = process.argv.slice(2), env = process.env) {
const platformArg = (argv.find((a) => a.startsWith("--platform=")) || "").split("=")[1];
const archArg = (argv.find((a) => a.startsWith("--arch=")) || "").split("=")[1];
let hostTarget = null;
if (argv.includes("--host")) {
try {
hostTarget = resolveHostTarget({ platform: platformArg || process.platform, arch: archArg || process.arch });
} catch (err) {
warn(`${err.message} - skipping host mosh-client fetch.`);
return 0;
}
}
let release = env.MOSH_BIN_RELEASE;
if (!release && argv.includes("--resolve-release")) {
release = await resolveMoshBinRelease(env);
}
if (!release) {
log("MOSH_BIN_RELEASE is unset - skipping. Set it (e.g. moshcatty-0.1.8) to bundle mosh-client into the package.");
return 0;
}
// Reject pre-0.1.8 pins (unsafe local backspace prediction for #2275) 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 platformFilter = hostTarget?.platform || platformArg;
const archFilter = hostTarget?.arch || archArg;
log(`release=${release} owner=${owner} repo=${repo}`);
const sums = await fetchSums(baseUrl, { allowUnverified });
let ok = 0;
let total = 0;
for (const target of TARGETS) {
if (platformFilter && target.platform !== platformFilter) continue;
if (archFilter && target.arch !== archFilter) continue;
total += 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`);
return 0;
}
if (require.main === module) {
main().catch((err) => {
console.error(`[fetch-mosh-binaries] FATAL ${err.message}`);
process.exit(1);
});
}
module.exports = {
TARGETS,
parseMoshBinRepository,
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
parseSums,
validateTarEntries,
assertExtractedTreeSafe,
normalizeMoshCattyBundle,
unpackTarGz,
main,
};

View File

@@ -0,0 +1,328 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { execFile, execFileSync } = require("node:child_process");
const { promisify } = require("node:util");
const crypto = require("node:crypto");
const script = path.resolve(__dirname, "fetch-mosh-binaries.cjs");
const execFileAsync = promisify(execFile);
const {
parseMoshBinRepository,
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
TARGETS,
} = require("./fetch-mosh-binaries.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-fetch-mosh-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
function sha256(buf) {
return crypto.createHash("sha256").update(buf).digest("hex");
}
function makeTarGz(t, entries) {
const dir = makeTmp(t);
for (const [name, contents] of Object.entries(entries)) {
const file = path.join(dir, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
}
const tarPath = path.join(makeTmp(t), "bundle.tar.gz");
execFileSync("tar", ["-czf", tarPath, "-C", dir, "."], { stdio: "pipe" });
return fs.readFileSync(tarPath);
}
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: "binaricat",
repo: "MoshCatty",
});
assert.deepEqual(
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: "win32", arch: "x64" }), {
platform: "win32",
arch: "x64",
});
assert.throws(() => resolveHostTarget({ platform: "freebsd", arch: "x64" }), /No bundled mosh-client target/);
});
test("tar archive invocation uses a relative archive name for Windows paths", () => {
assert.deepEqual(
resolveTarArchiveInvocation(
"C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\netcatty-mosh-abc\\bundle.tar.gz",
"win32",
),
{
cwd: "C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\netcatty-mosh-abc",
archive: "bundle.tar.gz",
},
);
});
test("replaceDir falls back to copy when rename crosses devices", (t) => {
const root = makeTmp(t);
const src = path.join(root, "src");
const dest = path.join(root, "dest");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "mosh-client.exe"), "exe");
const originalRenameSync = fs.renameSync;
fs.renameSync = (from, to) => {
if (from === src && to === dest) {
const error = new Error("cross-device link not permitted");
error.code = "EXDEV";
throw error;
}
return originalRenameSync(from, to);
};
t.after(() => {
fs.renameSync = originalRenameSync;
});
replaceDir(src, dest);
assert.equal(fs.existsSync(src), false);
assert.equal(fs.readFileSync(path.join(dest, "mosh-client.exe"), "utf8"), "exe");
});
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 { stderr } = await execFileAsync(
process.execPath,
[script, "--host", "--platform=win32", "--arch=arm64"],
{
env: {
...process.env,
MOSH_BIN_RELEASE: "moshcatty-0.1.8",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
},
);
assert.match(stderr, /No bundled mosh-client target for win32-arm64/);
assert.equal(fs.existsSync(resDir), false);
});
test("fetch-mosh-binaries rejects MoshCatty releases before 0.1.8", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "moshcatty-0.1.7",
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/below minimum moshcatty-0\.1\.8/,
);
assert.equal(fs.existsSync(resDir), false);
});
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.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.8",
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",
});
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.8",
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"), "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 unpacks pure Linux MoshCatty tarball", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "linux-client",
});
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: "moshcatty-0.1.8",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
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 rejects tarball without mosh-client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"README.txt": "no binary here",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "moshcatty-0.1.8",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/did not contain mosh-client/,
);
});
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.8",
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, "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", "link.exe"], { 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: "moshcatty-0.1.8",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/symbolic link/,
);
});

View File

@@ -0,0 +1,104 @@
/* global __dirname, process, setTimeout, clearTimeout, console */
// Opt-in real Claude Code permission check; all model traffic stays on loopback.
// Usage: node scripts/fixtures/research-permission-probe.cjs [claude executable]
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { prepareAiCliSettings } = require('../ai-automation.cjs');
const repository = path.resolve(__dirname, '../..');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-research-permission-'));
const settings = path.join(root, 'settings.json');
prepareAiCliSettings({ configPath: settings, denyWeb: true, allowBrave: true, allowWrites: false });
for (const name of ['web-search', 'web-fetch', 'unrelated-helper']) {
fs.writeFileSync(path.join(root, name), '#!/bin/sh\nprintf RESEARCH_FIXTURE_OK\n', { mode: 0o755 });
}
const workflow = fs.readFileSync(path.join(repository, '.github/workflows/ai-automation.yml'), 'utf8');
const blocks = [...workflow.matchAll(/--allowedTools "Read" "Bash\(web-search \*\)"([\s\S]*?)--disallowedTools/g)];
assert.equal(blocks.length, 2, 'classification and follow-up research must both be checked');
let command;
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', data => { raw += data; });
req.on('end', () => {
const body = JSON.parse(raw || '{}');
if (req.url.includes('count_tokens')) {
res.setHeader('Content-Type', 'application/json');
res.end('{"input_tokens":10}');
return;
}
const finished = (body.messages || []).some(message => Array.isArray(message.content)
&& message.content.some(item => item.type === 'tool_result'));
const block = finished ? { type: 'text', text: 'DONE' }
: { type: 'tool_use', id: 'tool_fixture', name: 'Bash', input: { command } };
const message = { id: 'msg_fixture', type: 'message', role: 'assistant', model: body.model,
content: [block], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null,
usage: { input_tokens: 20, output_tokens: 10 } };
if (!body.stream) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(message));
return;
}
res.setHeader('Content-Type', 'text/event-stream');
const emit = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
emit('message_start', { message: { ...message, content: [], stop_reason: null } });
emit('content_block_start', { index: 0, content_block: finished
? { type: 'text', text: '' } : { ...block, input: {} } });
emit('content_block_delta', { index: 0, delta: finished
? { type: 'text_delta', text: 'DONE' }
: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
emit('content_block_stop', { index: 0 });
emit('message_delta', { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 10 } });
emit('message_stop', {});
res.end();
});
});
async function check(index, helper, expectRun, baseline = false) {
const allowed = [...blocks[index][0].split('--disallowedTools')[0].matchAll(/"([^"]+)"/g)]
.map(match => match[1].replaceAll('$research_dir', root))
.filter(rule => !baseline || !rule.includes(root));
command = `${path.join(root, helper)} smoke 2>&1 | head -40`;
const args = ['--bare', '-p', '--permission-mode', 'dontAsk', '--settings', settings,
'--allowedTools', ...allowed, '--disallowedTools', 'WebSearch', 'WebFetch', 'Edit', 'Write', 'NotebookEdit',
'--output-format', 'stream-json', '--verbose', '--model', 'claude-sonnet-4-6', 'Run the approved helper once.'];
const env = { PATH: `${root}${path.delimiter}${process.env.PATH}`, HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR, CLAUDE_CONFIG_DIR: path.join(root, 'config'),
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`, ANTHROPIC_AUTH_TOKEN: 'synthetic-local-fixture',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
let output = '', errors = '';
const code = await new Promise((resolve, reject) => {
const child = spawn(process.argv[2] || 'claude', args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
const timeout = setTimeout(() => child.kill('SIGTERM'), 20000);
child.stdout.on('data', data => { output += data; });
child.stderr.on('data', data => { errors += data; });
child.once('error', error => { clearTimeout(timeout); reject(error); });
child.once('exit', value => { clearTimeout(timeout); resolve(value); });
});
assert.equal(code, 0, errors);
const events = output.trim().split('\n').map(line => JSON.parse(line));
const result = events.findLast(event => event.type === 'result');
assert.ok(result, 'Claude must finish the model/tool turn');
const ran = events.some(event => Array.isArray(event.message?.content)
&& event.message.content.some(item => item.type === 'tool_result'
&& JSON.stringify(item.content).includes('RESEARCH_FIXTURE_OK') && !item.is_error));
assert.equal(ran, expectRun, `${index}:${helper}:baseline=${baseline}`);
assert.equal((result.permission_denials || []).length, expectRun ? 0 : 1);
console.log(JSON.stringify({ route: index === 0 ? 'classification' : 'follow-up', helper, baseline, ran }));
}
(async () => {
try {
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
for (const index of [0, 1]) {
await check(index, 'web-search', false, true);
await check(index, 'web-search', true);
await check(index, 'web-fetch', true);
await check(index, 'unrelated-helper', false);
}
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}
})().catch(error => { console.error(error.message); process.exitCode = 1; });

View File

@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Generate runtime app icon variants from public/icon.svg.
Outputs desktop PNGs under public/icons/variants/ and HIG-sized macOS PNGs
under public/icons/variants/macos/ for Electron dock/taskbar switching.
Run: python3 scripts/generate-app-icon-variants.py
Requires: rsvg-convert (librsvg)
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE_SVG = ROOT / "public" / "icon.svg"
OUT_DIR = ROOT / "public" / "icons" / "variants"
MACOS_OUT_DIR = OUT_DIR / "macos"
MACOS_RUNTIME_VIEWBOX = "0 0 1024 1024"
DETAIL_COLORS = [
"#1f2657",
"#18214c",
"#0c1943",
"#505c83",
"#919ab0",
"#022551",
"#032551",
"#0c1a4d",
"#98a2bf",
"#9ea6be",
"#132152",
"#6c7794",
"#6f7b97",
"#a8aec5",
"#677393",
"#01103f",
"#7581a0",
"#a7aec3",
"#adb2c9",
"#bec4d7",
"#9ba0b8",
"#9aa5bc",
"#adb1c6",
"#c9d0dc",
"#b5bfcb",
"#8193aa",
]
RAINBOW_GRADIENT = """
<linearGradient id="netcatty-rainbow" x1="180" y1="1020" x2="1080" y2="260" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#EF4444"/>
<stop offset="16%" stop-color="#F97316"/>
<stop offset="33%" stop-color="#EAB308"/>
<stop offset="50%" stop-color="#22C55E"/>
<stop offset="66%" stop-color="#06B6D4"/>
<stop offset="83%" stop-color="#3B82F6"/>
<stop offset="100%" stop-color="#A855F7"/>
</linearGradient>
"""
WHITE_BG = {
"bg": "#FFFFFF",
"border_color": "#CBD5E1",
"border_opacity": "0.85",
}
WHITE_CAT_FACE = "#FFFFFF"
VARIANTS: dict[str, dict[str, str] | None] = {
"original": None,
"bright": {
"bg": "#0EA5E9",
"cat": "#FFFFFF",
"cat_alt": "#F0F9FF",
"detail": "#0369A1",
"border_color": "#ffffff",
"border_opacity": "0.55",
},
"dark": {
"bg": "#0F172A",
"cat": "#F8FAFC",
"cat_alt": "#E2E8F0",
"detail": "#334155",
"border_color": "#ffffff",
"border_opacity": "0.35",
},
"colorful": {
"bg": "#EA580C",
"cat": "#FFFFFF",
"cat_alt": "#FFF7ED",
"detail": "#C2410C",
"border_color": "#ffffff",
"border_opacity": "0.5",
},
"high-contrast": {
"bg": "#000000",
"cat": "#FACC15",
"cat_alt": "#FDE047",
"detail": "#A16207",
"border_color": "#ffffff",
"border_opacity": "0.9",
},
"white-navy": {
**WHITE_BG,
"cat": "#002551",
"cat_alt": "#0B3D78",
"detail": WHITE_CAT_FACE,
},
"white-sky": {
**WHITE_BG,
"cat": "#0284C7",
"cat_alt": "#38BDF8",
"detail": WHITE_CAT_FACE,
},
"white-rose": {
**WHITE_BG,
"cat": "#E11D48",
"cat_alt": "#FB7185",
"detail": WHITE_CAT_FACE,
},
"white-emerald": {
**WHITE_BG,
"cat": "#059669",
"cat_alt": "#34D399",
"detail": WHITE_CAT_FACE,
},
"white-amber": {
**WHITE_BG,
"cat": "#D97706",
"cat_alt": "#FBBF24",
"detail": WHITE_CAT_FACE,
},
"white-violet": {
**WHITE_BG,
"cat": "#7C3AED",
"cat_alt": "#A78BFA",
"detail": WHITE_CAT_FACE,
},
"rainbow": {
**WHITE_BG,
"mode": "rainbow",
"detail": WHITE_CAT_FACE,
},
}
def load_template() -> str:
if not SOURCE_SVG.exists():
raise SystemExit(f"source svg not found: {SOURCE_SVG}")
return SOURCE_SVG.read_text(encoding="utf-8")
def set_viewbox(svg: str, viewbox: str) -> str:
out, count = re.subn(r'viewBox="[^"]+"', f'viewBox="{viewbox}"', svg, count=1)
if count != 1:
raise SystemExit("source svg is missing a root viewBox")
return out
def inject_rainbow_gradient(svg: str) -> str:
if "id=\"netcatty-rainbow\"" in svg:
return svg
return svg.replace("<defs>", f"<defs>{RAINBOW_GRADIENT}", 1)
def apply_solid_variant(svg: str, spec: dict[str, str]) -> str:
out = svg
out = out.replace('fill="#002551"', f'fill="{spec["bg"]}"', 1)
out = out.replace('fill="#f9f9f9"', f'fill="{spec["cat"]}"')
out = out.replace('fill="#f8f8f9"', f'fill="{spec["cat_alt"]}"')
for color in DETAIL_COLORS:
out = out.replace(f'fill="{color}"', f'fill="{spec["detail"]}"')
border_color = spec.get("border_color", "#ffffff")
border_opacity = spec.get("border_opacity", "0.4")
out = re.sub(
r'stroke="#ffffff" stroke-opacity="[^"]+"',
f'stroke="{border_color}" stroke-opacity="{border_opacity}"',
out,
count=1,
)
return out
def apply_rainbow_variant(svg: str, spec: dict[str, str]) -> str:
out = inject_rainbow_gradient(svg)
out = out.replace('fill="#002551"', f'fill="{spec["bg"]}"', 1)
rainbow_fill = 'fill="url(#netcatty-rainbow)"'
out = out.replace('fill="#f9f9f9"', rainbow_fill)
out = out.replace('fill="#f8f8f9"', rainbow_fill)
face_color = spec.get("detail", WHITE_CAT_FACE)
for color in DETAIL_COLORS:
out = out.replace(f'fill="{color}"', f'fill="{face_color}"')
border_color = spec.get("border_color", "#CBD5E1")
border_opacity = spec.get("border_opacity", "0.85")
out = re.sub(
r'stroke="#ffffff" stroke-opacity="[^"]+"',
f'stroke="{border_color}" stroke-opacity="{border_opacity}"',
out,
count=1,
)
return out
def apply_variant(svg: str, spec: dict[str, str]) -> str:
if spec.get("mode") == "rainbow":
return apply_rainbow_variant(svg, spec)
return apply_solid_variant(svg, spec)
def render_png(svg_content: str, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["rsvg-convert", "-w", "1024", "-h", "1024", "-o", str(target)],
input=svg_content.encode("utf-8"),
check=True,
)
def main() -> None:
desktop_template = load_template()
macos_template = set_viewbox(desktop_template, MACOS_RUNTIME_VIEWBOX)
OUT_DIR.mkdir(parents=True, exist_ok=True)
MACOS_OUT_DIR.mkdir(parents=True, exist_ok=True)
for variant_id, spec in VARIANTS.items():
if spec is None:
desktop_path = OUT_DIR / f"{variant_id}.png"
render_png(desktop_template, desktop_path)
print(f"wrote {desktop_path.relative_to(ROOT)}")
macos_path = MACOS_OUT_DIR / f"{variant_id}.png"
render_png(macos_template, macos_path)
print(f"wrote {macos_path.relative_to(ROOT)}")
continue
desktop_path = OUT_DIR / f"{variant_id}.png"
render_png(apply_variant(desktop_template, spec), desktop_path)
print(f"wrote {desktop_path.relative_to(ROOT)}")
macos_path = MACOS_OUT_DIR / f"{variant_id}.png"
render_png(apply_variant(macos_template, spec), macos_path)
print(f"wrote {macos_path.relative_to(ROOT)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { AGENT_KINDS, listAgentToolSpecs, listCattyToolSpecs } = require("../electron/capabilities/codegen/toolSurfaces.cjs");
const generatedDir = path.join(
__dirname,
"../infrastructure/ai/harness/generated",
);
function writeSpecs(filename, specs) {
const outputPath = path.join(generatedDir, filename);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify(specs, null, 2)}\n`, "utf8");
return { outputPath, count: specs.length };
}
const sidebar = writeSpecs("cattyToolSpecs.json", listCattyToolSpecs());
const globalAgent = writeSpecs(
"globalAgentToolSpecs.json",
listAgentToolSpecs(AGENT_KINDS.GLOBAL),
);
process.stdout.write(`Wrote ${sidebar.count} sidebar tool specs to ${sidebar.outputPath}\n`);
process.stdout.write(`Wrote ${globalAgent.count} global agent tool specs to ${globalAgent.outputPath}\n`);

View File

@@ -0,0 +1,33 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { AGENT_KINDS, listAgentToolSpecs, listCattyToolSpecs } = require("../electron/capabilities/codegen/toolSurfaces.cjs");
const GENERATED_DIR = path.join(
__dirname,
"..",
"infrastructure",
"ai",
"harness",
"generated",
);
test("committed cattyToolSpecs.json matches listCattyToolSpecs()", () => {
const committed = JSON.parse(
fs.readFileSync(path.join(GENERATED_DIR, "cattyToolSpecs.json"), "utf8"),
);
const fresh = listCattyToolSpecs();
assert.deepEqual(committed, fresh);
});
test("committed globalAgentToolSpecs.json matches listAgentToolSpecs(global)", () => {
const committed = JSON.parse(
fs.readFileSync(path.join(GENERATED_DIR, "globalAgentToolSpecs.json"), "utf8"),
);
const fresh = listAgentToolSpecs(AGENT_KINDS.GLOBAL);
assert.deepEqual(committed, fresh);
});

View File

@@ -0,0 +1,60 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const cacheDir = path.join(root, "node_modules", ".cache", "netcatty", "codex-app-server-schema");
const sourceFile = path.join(cacheDir, "codex_app_server_protocol.schemas.json");
const targetFile = path.join(
root,
"electron",
"bridges",
"aiBridge",
"codexAppServer",
"protocol.schema.json",
);
const check = process.argv.includes("--check");
fs.rmSync(cacheDir, { recursive: true, force: true });
fs.mkdirSync(cacheDir, { recursive: true });
const codexEntry = require.resolve("@openai/codex/bin/codex.js");
execFileSync(process.execPath, [
codexEntry,
"app-server",
"generate-json-schema",
"--experimental",
"--out",
cacheDir,
], {
cwd: root,
stdio: "inherit",
env: process.env,
});
function sortJson(value) {
if (Array.isArray(value)) return value.map(sortJson);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, sortJson(value[key])]),
);
}
const generated = Buffer.from(
`${JSON.stringify(sortJson(JSON.parse(fs.readFileSync(sourceFile, "utf8"))), null, 2)}\n`,
);
if (check) {
const current = fs.existsSync(targetFile) ? fs.readFileSync(targetFile) : null;
if (!current || !current.equals(generated)) {
console.error("Codex App Server protocol schema is out of date. Run npm run generate:codex-app-server-schema.");
process.exitCode = 1;
}
} else {
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
fs.writeFileSync(targetFile, generated);
console.log(`Updated ${path.relative(root, targetFile)}`);
}
fs.rmSync(cacheDir, { recursive: true, force: true });

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Generate build/icons/* from public/icon-win.png for Linux packaging.
# electron-builder installs these into /usr/share/icons/hicolor/<size>/apps/.
#
# Requires ImageMagick (`convert`). Run from repo root:
# ./scripts/generate-linux-icons.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SOURCE="$ROOT/public/icon-win.png"
OUT_DIR="$ROOT/build/icons"
if command -v magick >/dev/null 2>&1; then
CONVERT=(magick)
elif command -v convert >/dev/null 2>&1; then
CONVERT=(convert)
else
echo "error: ImageMagick is required (magick or convert)" >&2
exit 1
fi
if [[ ! -f "$SOURCE" ]]; then
echo "error: source icon not found: $SOURCE" >&2
exit 1
fi
mkdir -p "$OUT_DIR"
for size in 16 32 48 64 128 256 512; do
"${CONVERT[@]}" "$SOURCE" -resize "${size}x${size}!" "$OUT_DIR/${size}x${size}.png"
echo "wrote build/icons/${size}x${size}.png"
done

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Generate build/icon.icns from the macOS app artwork using Apple's native
# iconutil pipeline. Keep hand-tuned 16px/32px 1x artwork: shrinking the
# large macOS artwork turns its subtle highlight into a bright one-pixel frame.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SOURCE="$ROOT/public/icon.png"
VECTOR_SOURCE="$ROOT/public/icon.svg"
OUTPUT="$ROOT/build/icon.icns"
TEMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/netcatty-mac-icon.XXXXXX")"
ICONSET="$TEMP_ROOT/icon.iconset"
SMALL_VECTOR="$TEMP_ROOT/icon-small.svg"
SMALL_RASTER="$TEMP_ROOT/icon-small.png"
cleanup() {
find "$TEMP_ROOT" -type f -delete
rmdir "$ICONSET" "$TEMP_ROOT"
}
trap cleanup EXIT
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "error: generating ICNS requires macOS iconutil and sips" >&2
exit 1
fi
for source in "$SOURCE" "$VECTOR_SOURCE"; do
if [[ ! -f "$source" ]]; then
echo "error: source icon not found: $source" >&2
exit 1
fi
done
mkdir -p "$ICONSET"
# The large artwork has a subtle outer highlight. At 1x it collapses into a
# bright one-pixel frame, so omit only that final SVG rect for 16px/32px.
awk '
BEGIN { skip = 0 }
/<rect x="104\.0" y="104\.0"/ { skip = 1 }
!skip { print }
skip && /\/>/ { skip = 0 }
' "$VECTOR_SOURCE" > "$SMALL_VECTOR"
sips -s format png "$SMALL_VECTOR" --out "$SMALL_RASTER" >/dev/null
for size in 16 32 128 256 512; do
retina_size=$((size * 2))
if [[ "$size" = 16 || "$size" = 32 ]]; then
sips -z "$size" "$size" "$SMALL_RASTER" \
--out "$ICONSET/icon_${size}x${size}.png" >/dev/null
else
sips -z "$size" "$size" "$SOURCE" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
fi
sips -z "$retina_size" "$retina_size" "$SOURCE" \
--out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil --convert icns "$ICONSET" --output "$OUTPUT"
echo "wrote build/icon.icns"

View File

@@ -0,0 +1,377 @@
import { readFile, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const schemaPath = path.join(
rootDir,
"packages/plugin-contract/schema/plugin-contract.schema.json",
);
const generatedTypesPath = path.join(
rootDir,
"packages/plugin-contract/src/generated/plugin-contract.ts",
);
const generatedLimitsPath = path.join(
rootDir,
"packages/plugin-contract/src/generated/plugin-contract-limits.ts",
);
const electronBundlePath = path.join(
rootDir,
"electron/plugins/generated/plugin-contract.schema.json",
);
const checkOnly = process.argv.includes("--check");
const typescriptTypeOverrides = new Map([
[
"ActivationEvent",
'"onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`',
],
[
"PluginHostProtocol",
"`plugin:${ContributionId}`",
],
[
"ImporterGroupDraft",
"string | { path: string; label?: string } | { path?: string; label: string }",
],
[
"ImporterHostDraft",
`({
id?: string;
label?: string;
username?: string;
group?: string;
tags?: Array<string>;
os?: "linux" | "windows" | "macos";
deviceType?: "general" | "network";
identityId?: string;
identityFileId?: string;
telnetIdentityId?: string;
notes?: string;
theme?: string;
sftpEncoding?: string;
sftpFileProtocol?: "auto" | "sftp" | "scp";
moshEnabled?: boolean;
etEnabled?: boolean;
telnetEnabled?: boolean;
sftpSudo?: boolean;
requiresMfa?: boolean;
useSshAgent?: boolean;
identitiesOnly?: boolean;
agentForwarding?: boolean;
x11Forwarding?: boolean;
showLineTimestamps?: boolean;
disableDynamicTabTitle?: boolean;
pinned?: boolean;
autoOpenSftpPanel?: boolean;
sftpFollowTerminalCwd?: boolean;
port?: number;
telnetPort?: number;
etPort?: number;
keepaliveInterval?: number;
keepaliveCountMax?: number;
} & ({
hostname: string;
protocol?: "ssh" | "telnet" | "mosh" | "et" | "local" | "serial";
pluginConnection?: never;
} | {
hostname?: string;
protocol: PluginHostProtocol;
pluginConnection: ImporterPluginConnectionDraft;
}))`,
],
[
"ImporterKeyDraft",
`({
id?: string;
label: string;
type: "RSA" | "ECDSA" | "ED25519";
publicKey?: string;
certificate?: string;
passphrase?: string;
category?: "key" | "certificate" | "identity";
} & ({
privateKey: string;
filePath?: never;
} | {
privateKey?: never;
filePath: string;
}))`,
],
]);
const schemaText = await readFile(schemaPath, "utf8");
const schema = JSON.parse(schemaText);
if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") {
throw new Error("Plugin contract must use JSON Schema 2020-12");
}
if (!schema.$id?.includes("/0.1.0-internal/")) {
throw new Error("Plugin contract $id must include the internal API version");
}
if (!schema.$defs || typeof schema.$defs !== "object") {
throw new Error("Plugin contract must define $defs");
}
const jsonValueLimits = schema.$defs.JsonValueLimits?.const;
if (!Number.isSafeInteger(jsonValueLimits?.maxDepth) || jsonValueLimits.maxDepth < 1) {
throw new Error("JsonValueLimits.maxDepth must be a positive safe integer");
}
if (!Number.isSafeInteger(jsonValueLimits?.maxNodes) || jsonValueLimits.maxNodes < 1) {
throw new Error("JsonValueLimits.maxNodes must be a positive safe integer");
}
const wireIntegerLimits = schema.$defs.WireIntegerLimits?.const;
if (wireIntegerLimits?.maxSafeInteger !== Number.MAX_SAFE_INTEGER) {
throw new Error("WireIntegerLimits.maxSafeInteger must equal Number.MAX_SAFE_INTEGER");
}
const rpcLimits = schema.$defs.RpcLimits?.const;
if (!Number.isSafeInteger(rpcLimits?.maxJsonBytes) || rpcLimits.maxJsonBytes < 1) {
throw new Error("RpcLimits.maxJsonBytes must be a positive safe integer");
}
for (const [definitionName, minimum] of [
["SafeUnsignedInteger", 0],
["SafePositiveInteger", 1],
]) {
const definition = schema.$defs[definitionName];
if (definition?.type !== "integer"
|| definition.minimum !== minimum
|| definition.maximum !== wireIntegerLimits.maxSafeInteger) {
throw new Error(
`${definitionName} must be bounded from ${minimum} through WireIntegerLimits.maxSafeInteger`,
);
}
}
const rpcErrorCodes = [
...(schema.$defs.JsonRpcStandardErrorCode?.enum ?? []),
...(schema.$defs.PluginWireErrorCode?.enum ?? []),
];
if (rpcErrorCodes.length === 0
|| rpcErrorCodes.some((code) => !Number.isSafeInteger(code))
|| new Set(rpcErrorCodes).size !== rpcErrorCodes.length) {
throw new Error("RPC error code definitions must contain unique safe integers");
}
const streamLimits = schema.$defs.StreamLimits?.const;
if (!Number.isSafeInteger(streamLimits?.maxFrameJsonBytes)
|| streamLimits.maxFrameJsonBytes < streamLimits?.maxChunkBytes) {
throw new Error("StreamLimits.maxFrameJsonBytes must cover one maximum stream chunk");
}
const terminalInterceptorLimits = schema.$defs.TerminalInterceptorLimits?.const;
if (!Number.isSafeInteger(terminalInterceptorLimits?.maxChunkBytes)
|| terminalInterceptorLimits.maxChunkBytes < 1
|| !Number.isSafeInteger(terminalInterceptorLimits?.maxWindowBytes)
|| terminalInterceptorLimits.maxWindowBytes < terminalInterceptorLimits.maxChunkBytes) {
throw new Error("TerminalInterceptorLimits must define bounded chunk and window sizes");
}
const importerLimits = schema.$defs.ImporterLimits?.const;
if (!Number.isSafeInteger(importerLimits?.maxInputBytes)
|| !Number.isSafeInteger(importerLimits?.maxOutputBytes)
|| !Number.isSafeInteger(importerLimits?.maxRecordBytes)
|| !Number.isSafeInteger(importerLimits?.maxRecords)
|| importerLimits.maxInputBytes < 1
|| importerLimits.maxOutputBytes < 1
|| importerLimits.maxRecordBytes < 1
|| importerLimits.maxRecordBytes > importerLimits.maxOutputBytes
|| importerLimits.maxRecords < 1) {
throw new Error("ImporterLimits must define positive bounded input, output, record, and count limits");
}
const syncLimits = schema.$defs.SyncLimits?.const;
if (!Number.isSafeInteger(syncLimits?.maxObjectBytes)
|| !Number.isSafeInteger(syncLimits?.maxObjectKeyLength)
|| !Number.isSafeInteger(syncLimits?.maxRevisionLength)
|| !Number.isSafeInteger(syncLimits?.inlineObjectBytes)
|| syncLimits.maxObjectBytes < 1
|| syncLimits.maxObjectKeyLength < 1
|| syncLimits.maxRevisionLength < 1
|| syncLimits.inlineObjectBytes < 1
|| syncLimits.inlineObjectBytes > syncLimits.maxObjectBytes) {
throw new Error("SyncLimits must define positive bounded object, key, revision, and inline size limits");
}
for (const [name, minimum, maximum] of [
["TerminalInterceptorChunkByteLength", 0, terminalInterceptorLimits.maxChunkBytes],
["TerminalInterceptorWindowBytes", 1, terminalInterceptorLimits.maxWindowBytes],
["TerminalInterceptorCreditBytes", 0, terminalInterceptorLimits.maxWindowBytes],
]) {
const definition = schema.$defs[name];
if (definition?.type !== "integer"
|| definition.minimum !== minimum
|| definition.maximum !== maximum) {
throw new Error(`${name} must match the canonical TerminalInterceptorLimits range`);
}
}
const streamIdDefinition = schema.$defs.StreamId;
if (!Number.isSafeInteger(streamLimits?.maxStreamIdLength)
|| streamLimits.maxStreamIdLength < 1
|| streamIdDefinition?.type !== "string"
|| streamIdDefinition.minLength !== 1
|| streamIdDefinition.maxLength !== streamLimits.maxStreamIdLength) {
throw new Error("StreamId must match the canonical StreamLimits.maxStreamIdLength");
}
const streamFrameBranches = schema.$defs.StreamFrame?.oneOf;
if (!Array.isArray(streamFrameBranches)
|| streamFrameBranches.length === 0
|| streamFrameBranches.some(
(branch) => branch?.properties?.streamId?.$ref !== "#/$defs/StreamId",
)) {
throw new Error("Every StreamFrame branch must use the canonical StreamId definition");
}
for (const [name, minimum, maximum] of [
["StreamChunkByteLength", 0, streamLimits?.maxChunkBytes],
["StreamWindowBytes", streamLimits?.minWindowBytes, streamLimits?.maxWindowBytes],
["StreamCreditBytes", 1, streamLimits?.maxCreditBytes],
]) {
if (!Number.isSafeInteger(minimum)
|| !Number.isSafeInteger(maximum)
|| minimum < 0
|| maximum < minimum) {
throw new Error(`StreamLimits contains an invalid range for ${name}`);
}
const definition = schema.$defs[name];
if (definition?.type !== "integer"
|| definition.minimum !== minimum
|| definition.maximum !== maximum) {
throw new Error(`${name} must match the canonical StreamLimits range`);
}
}
function quoteProperty(name) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
}
function literal(value) {
return JSON.stringify(value);
}
function referenceName(reference) {
const prefix = "#/$defs/";
if (typeof reference !== "string" || !reference.startsWith(prefix)) {
throw new Error(`Unsupported schema reference: ${String(reference)}`);
}
return decodeURIComponent(reference.slice(prefix.length));
}
function schemaTypeToTs(node, level = 0) {
if (node === true) return "unknown";
if (node === false) return "never";
if (!node || typeof node !== "object") return "unknown";
if ("$ref" in node) return referenceName(node.$ref);
if ("const" in node) return literal(node.const);
if (Array.isArray(node.enum)) return node.enum.map(literal).join(" | ") || "never";
for (const keyword of ["oneOf", "anyOf"]) {
if (Array.isArray(node[keyword])) {
return node[keyword].map((entry) => `(${schemaTypeToTs(entry, level)})`).join(" | ");
}
}
if (Array.isArray(node.allOf)) {
return node.allOf.map((entry) => `(${schemaTypeToTs(entry, level)})`).join(" & ");
}
if (Array.isArray(node.type)) {
return node.type.map((entry) => schemaTypeToTs({ ...node, type: entry }, level)).join(" | ");
}
if (node.type === "null") return "null";
if (node.type === "string") return "string";
if (node.type === "number" || node.type === "integer") return "number";
if (node.type === "boolean") return "boolean";
if (node.type === "array") {
return `Array<${schemaTypeToTs(node.items ?? true, level)}>`;
}
if (node.type === "object" || node.properties || node.additionalProperties) {
const properties = node.properties ?? {};
const entries = Object.entries(properties);
const required = new Set(node.required ?? []);
const additional = node.additionalProperties;
if (entries.length === 0) {
if (additional && typeof additional === "object") {
return `{ [key: string]: ${schemaTypeToTs(additional, level)} }`;
}
return additional === false ? "Record<string, never>" : "Record<string, unknown>";
}
const indent = " ".repeat(level + 1);
const closingIndent = " ".repeat(level);
const body = entries.map(([name, propertySchema]) => {
const optional = required.has(name) ? "" : "?";
return `${indent}${quoteProperty(name)}${optional}: ${schemaTypeToTs(propertySchema, level + 1)};`;
});
let objectType = `{\n${body.join("\n")}\n${closingIndent}}`;
if (additional && typeof additional === "object") {
objectType = `(${objectType} & Record<string, ${schemaTypeToTs(additional, level)}>)`;
} else if (additional === true) {
objectType = `(${objectType} & Record<string, unknown>)`;
}
return objectType;
}
return "unknown";
}
const definitionNames = Object.keys(schema.$defs).sort((left, right) =>
left.localeCompare(right, "en"),
);
const generatedTypes = [
"// This file is generated from schema/plugin-contract.schema.json.",
"// Run `npm run generate:plugin-contract` after changing the contract.",
"// Do not edit this file directly.",
"",
...definitionNames.flatMap((name) => [
`export type ${name} = ${typescriptTypeOverrides.get(name) ?? schemaTypeToTs(schema.$defs[name])};`,
"",
]),
].join("\n");
const generatedLimits = [
"// This file is generated from schema/plugin-contract.schema.json.",
"// Run `npm run generate:plugin-contract` after changing the contract.",
"// Do not edit this file directly.",
"",
`export const PLUGIN_JSON_MAX_DEPTH = ${jsonValueLimits.maxDepth} as const;`,
`export const PLUGIN_JSON_MAX_NODES = ${jsonValueLimits.maxNodes} as const;`,
`export const PLUGIN_WIRE_MAX_SAFE_INTEGER = ${wireIntegerLimits.maxSafeInteger} as const;`,
`export const PLUGIN_RPC_MAX_JSON_BYTES = ${rpcLimits.maxJsonBytes} as const;`,
`export const PLUGIN_RPC_ERROR_CODES = ${JSON.stringify(rpcErrorCodes)} as const;`,
`export const PLUGIN_STREAM_MAX_ID_LENGTH = ${streamLimits.maxStreamIdLength} as const;`,
`export const PLUGIN_STREAM_MAX_CHUNK_BYTES = ${streamLimits.maxChunkBytes} as const;`,
`export const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = ${streamLimits.maxFrameJsonBytes} as const;`,
`export const PLUGIN_STREAM_MIN_WINDOW_BYTES = ${streamLimits.minWindowBytes} as const;`,
`export const PLUGIN_STREAM_MAX_WINDOW_BYTES = ${streamLimits.maxWindowBytes} as const;`,
`export const PLUGIN_STREAM_MAX_CREDIT_BYTES = ${streamLimits.maxCreditBytes} as const;`,
`export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = ${terminalInterceptorLimits.maxChunkBytes} as const;`,
`export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = ${terminalInterceptorLimits.maxWindowBytes} as const;`,
`export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = ${importerLimits.maxInputBytes} as const;`,
`export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = ${importerLimits.maxOutputBytes} as const;`,
`export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = ${importerLimits.maxRecordBytes} as const;`,
`export const PLUGIN_IMPORTER_MAX_RECORDS = ${importerLimits.maxRecords} as const;`,
`export const PLUGIN_SYNC_MAX_OBJECT_BYTES = ${syncLimits.maxObjectBytes} as const;`,
`export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = ${syncLimits.maxObjectKeyLength} as const;`,
`export const PLUGIN_SYNC_MAX_REVISION_LENGTH = ${syncLimits.maxRevisionLength} as const;`,
`export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = ${syncLimits.inlineObjectBytes} as const;`,
"",
].join("\n");
const normalizedSchema = `${JSON.stringify(schema, null, 2)}\n`;
async function assertCurrent(filePath, expected, label) {
let actual;
try {
actual = await readFile(filePath, "utf8");
} catch {
throw new Error(`${label} is missing. Run npm run generate:plugin-contract.`);
}
if (actual !== expected) {
throw new Error(`${label} is out of date. Run npm run generate:plugin-contract.`);
}
}
if (checkOnly) {
await assertCurrent(generatedTypesPath, generatedTypes, "Generated plugin TypeScript contract");
await assertCurrent(generatedLimitsPath, generatedLimits, "Generated plugin JSON limits");
await assertCurrent(electronBundlePath, normalizedSchema, "Electron plugin schema bundle");
console.log("Plugin contract generated artifacts are current.");
} else {
await mkdir(path.dirname(generatedTypesPath), { recursive: true });
await mkdir(path.dirname(electronBundlePath), { recursive: true });
await Promise.all([
writeFile(generatedTypesPath, generatedTypes, "utf8"),
writeFile(generatedLimitsPath, generatedLimits, "utf8"),
writeFile(electronBundlePath, normalizedSchema, "utf8"),
]);
console.log("Generated plugin TypeScript contract and Electron schema bundle.");
}

View File

@@ -0,0 +1,45 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
test("release notes include Arch pacman downloads for x64 and arm64", (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-release-note-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const script = path.join(__dirname, "..", ".github", "scripts", "generate-release-note.js");
execFileSync(process.execPath, [script], {
cwd: tmp,
env: {
...process.env,
VERSION: "1.2.3",
GITHUB_REF_NAME: "v1.2.3",
GITHUB_REPOSITORY: "binaricat/Netcatty",
GITHUB_SHA: "0123456789abcdef",
},
stdio: "pipe",
});
const notes = fs.readFileSync(path.join(tmp, "release_notes.md"), "utf8");
assert.match(notes, /ArchPackage x64/);
assert.match(notes, /ArchPackage arm64/);
assert.match(
notes,
/https:\/\/github\.com\/binaricat\/Netcatty\/releases\/download\/v1\.2\.3\/Netcatty-1\.2\.3-linux-x64\.pacman/,
);
assert.match(
notes,
/https:\/\/github\.com\/binaricat\/Netcatty\/releases\/download\/v1\.2\.3\/Netcatty-1\.2\.3-linux-aarch64\.pacman/,
);
assert.match(notes, /Code signing policy/);
assert.match(
notes,
/Free code signing provided by SignPath\.io, certificate by SignPath Foundation/,
);
assert.match(
notes,
/https:\/\/github\.com\/binaricat\/Netcatty\/blob\/v1\.2\.3\/CODE_SIGNING_POLICY\.md/,
);
});

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Generate public/tray-icon.ico from public/icon-win.png with multiple
sizes so Windows can pick the right pixel dimensions per DPI scale.
Sizes mirror what Explorer requests for the notification area on typical
DPI scale factors (100/125/150/175/200/250/300/400 %):
16, 20, 24, 32, 40, 48, 64
Run: python3 scripts/generate-tray-ico.py
Requires: Pillow (pip install Pillow)
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "public" / "icon-win.png"
OUT = ROOT / "public" / "tray-icon.ico"
SIZES = [(16, 16), (20, 20), (24, 24), (32, 32), (40, 40), (48, 48), (64, 64)]
def main() -> None:
if not SOURCE.exists():
raise SystemExit(f"source icon not found: {SOURCE}")
src = Image.open(SOURCE).convert("RGBA")
src.save(OUT, format="ICO", sizes=SIZES)
print(f"wrote {OUT.relative_to(ROOT)} ({', '.join(f'{w}x{h}' for w, h in SIZES)})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,154 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const buildWorkflow = fs.readFileSync(
path.join(__dirname, "..", ".github", "workflows", "build.yml"),
"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");
test("build workflow no longer installs removed legacy agent binaries", () => {
for (const stale of [
"@agentclientprotocol/claude-agent-acp",
"@agentclientprotocol/sdk",
"@zed-industries/codex-acp",
"codex-acp",
]) {
assert.equal(
buildWorkflow.includes(stale),
false,
`build workflow must not reference removed legacy package: ${stale}`,
);
}
});
test("build workflow uploads and releases Arch pacman artifacts", () => {
const releaseUploadPatterns = buildWorkflow.match(/release\/\*\.pacman/g) ?? [];
assert.equal(
releaseUploadPatterns.length,
3,
"mac/windows aggregate upload plus both Linux jobs must include release/*.pacman",
);
assert.ok(
buildWorkflow.includes("artifacts/*.pacman"),
"GitHub release file list must include downloaded pacman artifacts",
);
});
test("build workflow installs bsdtar for Arch pacman packaging", () => {
// arm64 (Debian) uses libarchive-tools; x64 (AlmaLinux 8) uses libarchive.
// Both packages provide bsdtar for electron-builder pacman metadata.
assert.match(
buildWorkflow,
/build-linux-arm64:[\s\S]*libarchive-tools/,
"Linux arm64 package job must install libarchive-tools for pacman metadata generation",
);
assert.match(
buildWorkflow,
/build-linux-x64:[\s\S]*\blibarchive\b/,
"Linux x64 package job must install libarchive (bsdtar) for pacman metadata generation",
);
// Pin a filename that actually exists on archive.debian.org so CI does not
// 404 when AlmaLinux's libarchive RPM ships without /usr/bin/bsdtar.
assert.match(
buildWorkflow,
/libarchive-tools_3\.3\.3-4\+deb10u1_amd64\.deb/,
"Linux x64 job must download a published Buster libarchive-tools deb for bsdtar",
);
});
test("build workflow initializes MSVC before Windows packaging", () => {
const msvcStep = /name:\s*Set up MSVC developer command prompt[\s\S]*?if:\s*matrix\.name == 'windows'[\s\S]*?uses:\s*ilammy\/msvc-dev-cmd@v1[\s\S]*?arch:\s*x64/;
assert.match(
buildWorkflow,
msvcStep,
"Windows package builds must initialize the MSVC developer prompt so cl.exe is available for the Windows Hello helper",
);
});
test("build workflow verifies RPM artifacts for both Linux architectures", () => {
assert.ok(
buildWorkflow.includes("bash scripts/verify-linux-rpm-artifact.sh x86_64"),
"Linux x64 package job must verify the RPM artifact",
);
assert.ok(
buildWorkflow.includes("bash scripts/verify-linux-rpm-artifact.sh aarch64"),
"Linux arm64 package job must verify the RPM artifact",
);
});
test("build workflow builds Linux x64 native modules in a glibc 2.28 container", () => {
// Keep x64 packages loadable on RHEL 8 / UOS / Deepin (see #2062).
// AlmaLinux 8 (glibc 2.28) replaces debian:buster so we still target the
// same glibc floor, but gcc-toolset-13 can compile Electron 42's -std=gnu++20
// (Buster's g++ 8 cannot).
const x64Job = buildWorkflow.match(
/build-linux-x64:[\s\S]*?(?=\n build-linux-arm64:)/,
);
assert.ok(x64Job, "build-linux-x64 job must be present before build-linux-arm64");
assert.match(
x64Job[0],
/container:[\s\S]*?image:\s*quay\.io\/almalinuxorg\/almalinux:8/,
"Linux x64 package job must build inside the official AlmaLinux 8 image for glibc 2.28 + modern GCC",
);
assert.equal(
x64Job[0].includes("debian:buster"),
false,
"Linux x64 package job must not use debian:buster (g++ 8 cannot build gnu++20 natives)",
);
assert.equal(
x64Job[0].includes("ubuntu-22.04"),
false,
"Linux x64 package job must not build on the host ubuntu-22.04 glibc",
);
assert.equal(
x64Job[0].includes("actions/setup-node@"),
false,
"Linux x64 package job must install Node inside the container like arm64",
);
assert.match(
x64Job[0],
/gcc-toolset-13-gcc-c\+\+/,
"Linux x64 job must install gcc-toolset-13 for C++20 native rebuilds",
);
assert.match(
x64Job[0],
/static-libstdc\+\+/,
"Linux x64 job must static-link libstdc++ so RHEL 8 stock libstdc++ is enough",
);
assert.match(
x64Job[0],
/unset LD_LIBRARY_PATH/,
"Linux x64 job must wrap packaging tools to clear portable-fpm LD_LIBRARY_PATH",
);
assert.match(
x64Job[0],
/for cmd in rpmbuild bsdtar/,
"Linux x64 job must wrap both rpmbuild and bsdtar for rpm/pacman targets",
);
assert.match(
x64Job[0],
/python3\.11/,
"Linux x64 job must use Python >=3.8 for node-gyp 12",
);
assert.equal(
x64Job[0].includes("actions/setup-python@"),
false,
"Linux x64 job must not rely on actions/setup-python inside the glibc container",
);
});
test("et binary build scripts retry dependency configure and pin ninja", () => {
for (const [name, script] of [
["linux", etLinuxScript],
["macos", etMacScript],
]) {
assert.match(script, /retry_command\(\)/, `${name} et build must retry transient dependency failures`);
assert.match(script, /retry_command cmake -S/, `${name} et build must retry CMake configure`);
assert.match(script, /NINJA_BIN=\$\(command -v ninja\)/, `${name} et build must resolve ninja before configure`);
assert.match(script, /-DCMAKE_MAKE_PROGRAM="\$NINJA_BIN"/, `${name} et build must pass the resolved ninja path`);
}
});

View File

@@ -0,0 +1,468 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const workflowsDir = path.join(__dirname, "..", ".github", "workflows");
const readWorkflow = (name) => fs.readFileSync(path.join(workflowsDir, name), "utf8");
const testWorkflow = readWorkflow("test.yml");
const buildWorkflow = readWorkflow("build.yml");
const aiWorkflow = readWorkflow("ai-automation.yml");
const etWorkflow = readWorkflow("build-et-binaries.yml");
const appBuilderPatch = fs.readFileSync(
path.join(__dirname, "..", "patches", "app-builder-lib+26.15.2.patch"),
"utf8",
);
const windowsEtBuild = fs.readFileSync(
path.join(__dirname, "build-et", "build-windows.ps1"),
"utf8",
);
const homebrewBump = fs.readFileSync(
path.join(__dirname, "..", ".github", "scripts", "bump-homebrew-cask.sh"),
"utf8",
);
const pullRequestPaths = buildWorkflow
.match(/pull_request:\s*\n\s*paths:\s*\n((?:\s+- "[^"]+"\s*\n)+)/)?.[1]
?.match(/^\s+- "([^"]+)"$/gm)
?.map((line) => line.match(/^\s+- "([^"]+)"$/)?.[1])
.filter(Boolean);
// Portable glob matcher for Node >=22 (path.matchesGlob only landed in 22.5).
const matchesGlob = (filePath, pattern) => {
let regex = "^";
for (let i = 0; i < pattern.length; ) {
if (pattern[i] === "*" && pattern[i + 1] === "*") {
if (pattern[i + 2] === "/") {
regex += "(?:.*/)?";
i += 3;
} else {
regex += ".*";
i += 2;
}
continue;
}
if (pattern[i] === "*") {
regex += "[^/]*";
i += 1;
continue;
}
if (pattern[i] === "?") {
regex += "[^/]";
i += 1;
continue;
}
regex += pattern[i].replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
i += 1;
}
regex += "$";
return new RegExp(regex).test(filePath);
};
const triggersPackageValidation = (filePath) => {
assert.ok(pullRequestPaths, "package workflow pull_request paths must be readable");
return pullRequestPaths.reduce((included, pattern) => {
const excluded = pattern.startsWith("!");
const glob = excluded ? pattern.slice(1) : pattern;
return matchesGlob(filePath, glob) ? !excluded : included;
}, false);
};
test("PR validation runs once per commit and includes a production build", () => {
assert.match(testWorkflow, /push:\s*\n\s*branches:\s*\n\s*- main/);
assert.doesNotMatch(testWorkflow, /branches:\s*\n\s*- "\*\*"/);
assert.match(testWorkflow, /name: lint-and-test\s*\n\s*runs-on: ubuntu-latest\s*\n\s*timeout-minutes: 20/);
assert.match(testWorkflow, /sudo apt-get install -y fish xvfb/);
assert.match(
testWorkflow,
/- name: Test terminal keyword highlight performance\s*\n\s*env:\s*\n\s*NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"\s*\n\s*# GitHub-hosted runners do not configure Electron's SUID sandbox helper\.\s*\n\s*run: xvfb-run -a \.\/node_modules\/\.bin\/electron --no-sandbox scripts\/xterm-keyword-highlight-performance\.live\.test\.cjs/,
);
assert.match(
buildWorkflow,
/- name: Test macOS Option column selection\s*\n\s*if: matrix\.name == 'macos'\s*\n\s*run: npm run test:xterm-macos-selection/,
);
assert.match(testWorkflow, /- name: Build\s*\n\s*run: npm run build/);
assert.doesNotMatch(testWorkflow, /\n mosh-windows-conpty:/);
});
test("package release concurrency is isolated per tag", () => {
assert.match(buildWorkflow, /format\('release-\{0\}', github\.ref\)/);
assert.doesNotMatch(buildWorkflow, /&& 'release' \|\| github\.ref/);
});
test("manual package validations do not share push concurrency", () => {
assert.match(
buildWorkflow,
/github\.event_name == 'workflow_dispatch' && format\('manual-\{0\}', github\.run_id\)/,
);
assert.ok(
buildWorkflow.indexOf("format('release-{0}', github.ref)") <
buildWorkflow.indexOf("format('manual-{0}', github.run_id)"),
"publishing a tag manually must still share that tag's release group",
);
});
test("package validation avoids duplicate branch runs and scopes PR builds", () => {
assert.match(buildWorkflow, /push:\s*\n\s*branches:\s*\n\s*- main/);
assert.doesNotMatch(buildWorkflow, /branches:\s*\n\s*- "\*\*"/);
assert.match(buildWorkflow, /pull_request:\s*\n\s*paths:/);
assert.doesNotMatch(buildWorkflow, /\n dedupe:/);
assert.doesNotMatch(buildWorkflow, /\n dedupe-result:/);
for (const packagedInput of [
"electron/**",
"infrastructure/config/terminalFlowConstants.*",
"public/icon*",
"scripts/afterPackMacUuid.cjs",
"scripts/beforePackCursorSdk.cjs",
"scripts/nodePtyConptyPatch.cjs",
"scripts/patch-xterm-macos-column-selection.cjs",
"scripts/xterm-macos-column-selection.live.test.cjs",
"scripts/linux/**",
"skills/**",
]) {
assert.ok(buildWorkflow.includes(`- "${packagedInput}"`), `${packagedInput} must trigger package validation`);
}
for (const excludedTestInput of [
"!electron/**/*.test.*",
"!electron/**/*.spec.*",
"!electron/**/__tests__/**",
"!electron/**/test/**",
"!electron/**/tests/**",
"!electron/**/example/**",
"!electron/**/examples/**",
"!electron/plugins/fixtures/**",
]) {
assert.ok(buildWorkflow.includes(`- "${excludedTestInput}"`), `${excludedTestInput} must stay out of package validation`);
}
assert.ok(
buildWorkflow.indexOf('- "electron/**"') < buildWorkflow.indexOf('- "!electron/**/*.test.*"'),
"packaged Electron files must be included before test-only exclusions",
);
for (const packagedPath of [
"electron/main.cjs",
"electron/entitlements.mac.plist",
"electron/bridges/terminalBridge.cjs",
"electron/preload/api.cjs",
"electron/shared/protocol.cjs",
"electron/mcp/server.cjs",
"electron/plugins/pluginManager.cjs",
"scripts/linux/after-install.tpl",
]) {
assert.equal(triggersPackageValidation(packagedPath), true, `${packagedPath} must trigger package validation`);
}
for (const testOnlyPath of [
"electron/main.test.cjs",
"electron/bridges/moshHandshake.test.cjs",
"electron/plugins/pluginManager.test.cjs",
"electron/plugins/fixtures/example/plugin.cjs",
]) {
assert.equal(triggersPackageValidation(testOnlyPath), false, `${testOnlyPath} must not trigger package validation`);
}
});
test("Windows packaging reuses its dependency install for the ConPTY smoke test", () => {
const packageMatrix = buildWorkflow.match(/\n build:\n[\s\S]*?(?=\n build-linux-x64:)/);
assert.ok(packageMatrix, "build matrix job must exist before build-linux-x64");
assert.match(packageMatrix[0], /Compile ConPTY test helpers/);
assert.match(packageMatrix[0], /Test Mosh handshake through ConPTY/);
assert.match(packageMatrix[0], /if: matrix\.name == 'windows'/);
assert.match(packageMatrix[0], /Restore Electron download cache/);
assert.match(packageMatrix[0], /actions\/cache@v6/);
assert.match(packageMatrix[0], /node electron\/bridges\/terminalBridge\.moshConpty\.integration\.cjs/);
});
test("package downloads use bounded retries and reusable caches", () => {
assert.match(buildWorkflow, /NPM_CONFIG_FETCH_RETRIES: "4"/);
assert.match(buildWorkflow, /NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"/);
assert.match(buildWorkflow, /NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "10000"/);
assert.equal(
(buildWorkflow.match(/restore-keys:\s*\|\s*\n\s*electron-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-/g) ?? [])
.length,
3,
"all package jobs must reuse compatible Electron downloads after lockfile changes",
);
const linuxX64 = buildWorkflow.match(/\n build-linux-x64:\n[\s\S]*?(?=\n build-linux-arm64:)/)?.[0];
const linuxArm64 = buildWorkflow.match(/\n build-linux-arm64:\n[\s\S]*?(?=\n release:)/)?.[0];
assert.ok(linuxX64, "Linux x64 package job must be readable");
assert.ok(linuxArm64, "Linux arm64 package job must be readable");
assert.match(linuxX64, /image: quay\.io\/almalinuxorg\/almalinux:8/);
assert.match(linuxX64, /dnf -y --setopt=retries=4 --setopt=timeout=30 install/);
assert.match(
linuxX64,
/curl -fsSL --retry 4 --retry-connrefused --connect-timeout 20 --max-time 300/g,
);
assert.match(linuxArm64, /apt-get -o Acquire::Retries=4 update/);
assert.match(
linuxArm64,
/name: Install build dependencies\s*\n\s*shell: bash\s*\n\s*run: \|\s*\n\s*set -euo pipefail/,
);
assert.match(linuxArm64, /apt-get -o Acquire::Retries=4 install -y/);
assert.match(
linuxArm64,
/curl -fsSL --retry 4 --retry-all-errors --connect-timeout 20 --max-time 300/,
);
});
test("stable releases propose Nix metadata through a pull request", () => {
const nixJob = buildWorkflow.match(/\n update-nix-release:\n[\s\S]*?(?=\n homebrew-tap:)/);
assert.ok(nixJob, "update-nix-release job must exist before homebrew-tap");
assert.doesNotMatch(nixJob[0], /git push origin HEAD:\$\{\{ github\.event\.repository\.default_branch \}\}/);
assert.match(nixJob[0], /gh pr create/);
assert.ok(
nixJob[0].includes("GH_TOKEN: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"),
"Nix PR creation must prefer the triage-capable token and safely fall back to the job token",
);
assert.ok(
nixJob[0].includes("token: ${{ secrets.RELEASE_TOKEN }}"),
"Nix branch pushes must keep using the release token",
);
assert.match(nixJob[0], /automation\/nix-release-/);
assert.match(nixJob[0], /candidate_tree/);
assert.match(nixJob[0], /remote_tree/);
assert.match(nixJob[0], /branch_prefix/);
assert.match(nixJob[0], /headRefName/);
assert.match(nixJob[0], /headRepositoryOwner\.login == \$owner/);
assert.match(nixJob[0], /desired_nix_blob="\$\(git hash-object -w nix\/release\.nix\)"/);
assert.match(nixJob[0], /existing_branch/);
assert.match(nixJob[0], /refs\/heads\/\$\{existing_branch\}/);
assert.match(nixJob[0], /git cat-file blob "\$desired_nix_blob" > nix\/release\.nix/);
assert.match(nixJob[0], /git diff --quiet -- nix\/release\.nix/);
assert.match(nixJob[0], /while IFS='\|' read -r existing existing_branch/);
assert.match(nixJob[0], /done <<<"\$existing_prs"/);
assert.doesNotMatch(nixJob[0], /\.\[0\] \/\//);
assert.match(
nixJob[0],
/--force-with-lease="refs\/heads\/\$\{existing_branch\}:\$\{remote_before\}"/,
);
assert.match(nixJob[0], /origin "HEAD:\$\{existing_branch\}"/);
assert.match(nixJob[0], /gh api --method GET "repos\/\$\{GITHUB_REPOSITORY\}\/pulls"/);
assert.match(nixJob[0], /-f head="\$\{REPO_OWNER\}:\$\{branch\}"/);
assert.doesNotMatch(nixJob[0], /gh pr list[^\n]*--head "\$\{REPO_OWNER\}:/);
assert.match(nixJob[0], /\.headRefName == \$prefix/);
assert.doesNotMatch(
nixJob[0],
/\.headRefName \| startswith\(\$prefix\)/,
"v1.2.30 must not be treated as a v1.2.3 metadata branch",
);
assert.match(nixJob[0], /test\("\^\[0-9\]\+-\[0-9\]\+\$"\)/);
assert.match(nixJob[0], /suffix=.*branch_prefix/);
assert.match(nixJob[0], /\[\[ "\$suffix" =~ \^\[0-9\]\+-\[0-9\]\+\$ \]\]/);
assert.match(nixJob[0], /ls-remote --heads origin "refs\/heads\/\$\{branch_prefix\}\*"/);
assert.match(nixJob[0], /GITHUB_RUN_ID/);
assert.match(nixJob[0], /--force-with-lease="refs\/heads\/\$\{branch\}:"/);
assert.doesNotMatch(nixJob[0], /--force-with-lease="\$\{branch\}:\$\{expected\}"/);
assert.ok(
nixJob[0].indexOf('gh pr list') < nixJob[0].indexOf('git switch -C'),
"an existing Nix PR must be reused before rebuilding its branch",
);
});
test("Homebrew tap updates retry push races without downgrading newer releases", () => {
assert.match(homebrewBump, /MAX_PUSH_ATTEMPTS/);
assert.match(homebrewBump, /version_is_newer/);
assert.match(homebrewBump, /git fetch --depth=1 origin main/);
assert.match(homebrewBump, /git switch -C main origin\/main/);
assert.match(homebrewBump, /for \(\(attempt=1; attempt<=MAX_PUSH_ATTEMPTS; attempt\+\+\)\)/);
assert.match(homebrewBump, /if version_is_newer "\$current_version" "\$VERSION"/);
assert.match(homebrewBump, /if push_output="\$\(git push origin HEAD:main 2>&1\)"/);
assert.match(homebrewBump, /grep -Eqi 'non-fast-forward\|fetch first' <<<"\$push_output"/);
assert.doesNotMatch(homebrewBump, /2> >\(tee/);
assert.match(homebrewBump, /Tap already has newer version/);
assert.match(homebrewBump, /Push raced with another release/);
});
test("Codex fix publishing treats a moved PR head as a stale result", () => {
const publishJob = aiWorkflow.match(/\n publish_codex_fix:\n[\s\S]*?(?=\n own_rerequest_codex:)/);
assert.ok(publishJob, "publish_codex_fix job must exist before own_rerequest_codex");
assert.match(publishJob[0], /--force-with-lease/);
assert.match(publishJob[0], /published=false/);
assert.match(publishJob[0], /remote_after/);
assert.match(publishJob[0], /exit 1/);
assert.match(publishJob[0], /steps\.publish\.outputs\.published == 'true'/);
});
test("issue implementation publishing tolerates competing automation runs", () => {
const publishJob = aiWorkflow.match(/\n publish_implement:\n[\s\S]*?(?=\n codex_loop:)/);
assert.ok(publishJob, "publish_implement job must exist before codex_loop");
assert.match(publishJob[0], /--force-with-lease/);
assert.match(publishJob[0], /candidate_tree/);
assert.match(publishJob[0], /remote_tree/);
assert.match(publishJob[0], /refs\/heads\/\$\{BRANCH\}:/);
assert.match(publishJob[0], /fetch --depth=1 origin[\s\\]*"\+refs\/heads\/\$\{BRANCH\}:refs\/remotes\/origin\/\$\{BRANCH\}"/);
assert.match(publishJob[0], /live_after/);
assert.match(publishJob[0], /changed while it was being checked/);
assert.match(publishJob[0], /reuse it without rewriting history/);
assert.doesNotMatch(publishJob[0], /:\$\{expected\}/);
assert.doesNotMatch(publishJob[0], /if \[\[ -n "\$expected" \]\]/);
assert.doesNotMatch(publishJob[0], /published=false/);
assert.match(publishJob[0], /remote_after/);
assert.match(publishJob[0], /exit 1/);
assert.match(publishJob[0], /echo "handoff=true" >> "\$GITHUB_OUTPUT"/);
assert.match(
publishJob[0],
/if: failure\(\) && steps\.existing\.outputs\.exists != 'true' && steps\.publish\.outputs\.handoff == 'true'/,
);
assert.doesNotMatch(publishJob[0], /steps\.publish\.outcome == 'failure'/);
assert.match(publishJob[0], /labels: \['ready-for-human'\]/);
assert.match(publishJob[0], /could not safely publish the implementation branch/);
assert.match(publishJob[0], /ai-publish-handoff:/);
assert.match(publishJob[0], /github\.paginate\(github\.rest\.issues\.listComments/);
assert.match(publishJob[0], /steps\.publish\.outputs\.published == 'true'/);
assert.match(publishJob[0], /group: ai-codex-head-/);
assert.match(publishJob[0], /status === 403 && createPermissionDenied/);
assert.match(publishJob[0], /resource not accessible by integration/);
assert.match(publishJob[0], /resource not accessible by personal access token/);
assert.match(publishJob[0], /not permitted to create/);
});
test("reused automation PRs still receive labels and one source-issue backlink", () => {
const openPr = aiWorkflow.match(
/\n - name: Open draft PR[\s\S]*?(?=\n - name: Request Codex review on implement PR)/,
);
assert.ok(openPr, "open-PR step must exist before Codex review request");
assert.match(openPr[0], /github\.rest\.issues\.addLabels/);
assert.match(openPr[0], /github\.paginate\(github\.rest\.issues\.listComments/);
assert.match(openPr[0], /auto\.hasAutomationPullRequestBacklink/);
assert.doesNotMatch(openPr[0], /if \(created\)/);
});
test("reused implementation PRs do not duplicate Codex requests for the same head", () => {
const requestCodex = aiWorkflow.match(
/\n - name: Request Codex review on implement PR[\s\S]*?(?=\n codex_loop:)/,
);
assert.ok(requestCodex, "implement Codex request step must exist before codex_loop");
assert.match(requestCodex[0], /github\.paginate\(github\.rest\.issues\.listComments/);
assert.match(requestCodex[0], /github\.rest\.pulls\.get/);
assert.match(requestCodex[0], /auto\.shouldSkipExternalCodexRerequest/);
assert.match(requestCodex[0], /headSha/);
assert.match(requestCodex[0], /OWN_ACTORS/);
assert.doesNotMatch(requestCodex[0], /process\.env\.HEAD_SHA/);
});
test("all own-PR Codex request paths serialize on the head branch", () => {
const codexLoop = aiWorkflow.match(/\n codex_loop:\n[\s\S]*?(?=\n publish_codex_fix:)/);
const ownRerequest = aiWorkflow.match(/\n own_rerequest_codex:\n[\s\S]*?(?=\n external_rerequest_codex:)/);
assert.ok(codexLoop, "codex_loop job must exist before publish_codex_fix");
assert.ok(ownRerequest, "own_rerequest_codex must exist before external_rerequest_codex");
assert.match(aiWorkflow, /head_ref: \$\{\{ steps\.decide\.outputs\.head_ref \}\}/);
assert.match(codexLoop[0], /group: ai-codex-head-/);
assert.match(ownRerequest[0], /group: ai-codex-head-/);
assert.match(codexLoop[0], /needs\.route\.outputs\.head_ref/);
assert.match(ownRerequest[0], /needs\.route\.outputs\.head_ref/);
assert.match(aiWorkflow, /head_ref: pr\.head\?\.ref \|\| ''/);
assert.match(
aiWorkflow,
/issue_comment[\s\S]*?github\.rest\.pulls\.get[\s\S]*?head_ref: pr\.head\?\.ref \|\| ''/,
);
});
test("scheduled Codex polling ignores review comments remapped from old heads", () => {
const pollJob = aiWorkflow.match(/\n codex_poll:\n[\s\S]*$/);
assert.ok(pollJob, "codex_poll job must exist");
assert.match(pollJob[0], /auto\.filterCodexReviewCommentsForHead\(\s*reviewComments,\s*pr\.head\.sha/);
assert.doesNotMatch(pollJob[0], /c\.commit_id \|\| c\.original_commit_id/);
});
test("clean Codex handoff updates labels without GraphQL-only organization scopes", () => {
const markReady = aiWorkflow.match(/\n - name: Mark PR ready after clean Codex[\s\S]*?(?=\n - name: Give up after max rounds)/);
assert.ok(markReady, "mark-ready step must exist before give-up step");
assert.match(markReady[0], /gh api/);
assert.match(markReady[0], /issues\/\$\{PULL_NUMBER\}\/labels/);
assert.match(markReady[0], /automation%3Acodex-loop/);
assert.match(markReady[0], /ready-for-human/);
assert.match(markReady[0], /labels\[\]=automation:codex-clean/);
assert.match(markReady[0], /labels\[\]=automation:bot-pr/);
assert.doesNotMatch(markReady[0], /nextCodexTerminalLabels/);
assert.doesNotMatch(markReady[0], /"PUT"/);
assert.doesNotMatch(markReady[0], /gh pr edit/);
});
test("permission handoffs use the established ready-for-human label", () => {
assert.doesNotMatch(aiWorkflow, /automation:needs-human/);
assert.match(aiWorkflow, /labels: \['ready-for-human'\]/);
assert.match(aiWorkflow, /-f 'labels\[\]=ready-for-human'/);
});
test("ET binary validation runs once and retries transient container pulls", () => {
assert.match(etWorkflow, /push:\s*\n\s*branches:\s*\n\s*- main/);
assert.doesNotMatch(etWorkflow, /branches:\s*\n\s*- "\*\*"/);
assert.match(etWorkflow, /Pull build container with retry/g);
assert.match(etWorkflow, /docker pull/);
assert.match(etWorkflow, /--pull=never/);
assert.match(etWorkflow, /Restore vcpkg download cache/g);
assert.match(etWorkflow, /VCPKG_DOWNLOADS/g);
assert.match(windowsEtBuild, /Invoke-WithRetry/);
});
test("ET pull requests reuse exact platform builds without weakening release builds", () => {
const buildJobs = [
["linux-x64", etWorkflow.match(/\n build-linux-x64:\n[\s\S]*?(?=\n build-linux-arm64:)/)?.[0]],
["linux-arm64", etWorkflow.match(/\n build-linux-arm64:\n[\s\S]*?(?=\n build-macos-universal:)/)?.[0]],
["macos-universal", etWorkflow.match(/\n build-macos-universal:\n[\s\S]*?(?=\n build-windows-x64:)/)?.[0]],
["windows-x64", etWorkflow.match(/\n build-windows-x64:\n[\s\S]*?(?=\n # ------------------------------------------------------------------\n # Windows arm64)/)?.[0]],
];
const skipOnExactPrCacheHit =
"if: github.event_name != 'pull_request' || steps.et-build-cache.outputs.cache-hit != 'true'";
for (const [platform, job] of buildJobs) {
assert.ok(job, `${platform} ET build job must be readable`);
assert.match(job, /- name: Restore cached PR build\s*\n\s*id: et-build-cache/);
assert.match(job, /if: github\.event_name == 'pull_request'/);
assert.match(job, /path: out\//);
assert.match(
job,
/key: et-pr-build-v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-\$\{\{ env\.ET_REF \}\}-\$\{\{ hashFiles\('\.github\/workflows\/build-et-binaries\.yml', 'scripts\/build-et\/\*\*'\) \}\}/,
);
assert.ok(job.includes(skipOnExactPrCacheHit), `${platform} must skip compilation on an exact PR cache hit`);
assert.match(
job,
/- name: Upload artifact[\s\S]*?if-no-files-found: error/,
`${platform} must fail instead of publishing an empty cached build`,
);
}
assert.equal(
(etWorkflow.match(new RegExp(skipOnExactPrCacheHit.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length,
14,
"all dependency setup and compilation steps must be skipped when the exact PR build is cached",
);
assert.doesNotMatch(
etWorkflow.match(/\n release:\n[\s\S]*$/)?.[0] ?? "",
/et-build-cache|Restore cached PR build/,
"manual releases must never reuse PR build outputs",
);
});
test("GitHub-owned actions use current Node 24 releases", () => {
const workflows = fs.readdirSync(workflowsDir)
.filter((name) => name.endsWith(".yml"))
.map((name) => [name, readWorkflow(name)]);
const expectedMajors = new Map([
["actions/checkout", "v7"],
["actions/setup-node", "v7"],
["actions/upload-artifact", "v7"],
["actions/download-artifact", "v8"],
["actions/github-script", "v9"],
["actions/cache", "v6"],
]);
for (const [name, source] of workflows) {
for (const [action, major] of expectedMajors) {
const uses = [...source.matchAll(new RegExp(`${action.replace("/", "\\/")}@(v\\d+)`, "g"))];
for (const match of uses) {
assert.equal(match[1], major, `${name} must use ${action}@${major}`);
}
}
}
});
test("electron-builder retries Fetch API server errors", () => {
assert.match(appBuilderPatch, /e\?\.response\?\.status/);
assert.match(appBuilderPatch, /responseStatus >= 500/);
});

View File

@@ -0,0 +1,74 @@
#!/bin/bash
if type update-alternatives >/dev/null 2>&1; then
# Remove previous link if it doesn't use update-alternatives
if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then
rm -f '/usr/bin/${executable}'
fi
update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
else
ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}'
fi
# Always set the chrome-sandbox SUID bit so its sandbox works as a fallback.
#
# The upstream electron-builder template gates this on a `unshare --user true`
# probe and only sets 4755 when unprivileged user namespaces look unavailable.
# But this post-install script runs as root under dpkg/rpm, and root can create
# a user namespace even when unprivileged userns is restricted (e.g. Ubuntu 23.10+
# with kernel.apparmor_restrict_unprivileged_userns=1). The probe therefore always
# passes at install time and leaves chrome-sandbox at 0755, so on machines where
# the app itself cannot use the userns sandbox Chromium aborts with:
# "The SUID sandbox helper binary was found, but is not configured correctly ...
# must be owned by root and have mode 4755".
#
# Setting 4755 unconditionally is the historical electron/chrome default and is a
# safe fallback: on hosts where the bundled AppArmor profile grants userns, Chromium
# still prefers the namespace sandbox; elsewhere the SUID sandbox keeps the app
# launchable. See https://github.com/binaricat/Netcatty/issues/2607.
chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || true
if hash update-mime-database 2>/dev/null; then
update-mime-database /usr/share/mime || true
fi
if hash update-desktop-database 2>/dev/null; then
update-desktop-database /usr/share/applications || true
fi
# FPM packages copy icons directly and bypass distro hooks (e.g. Arch pacman
# alpm hooks) that normally refresh the hicolor cache. Without this, KDE and
# other icon themes cannot resolve Icon=${executable} and show a generic icon.
if hash gtk-update-icon-cache 2>/dev/null; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
# Install apparmor profile. (Ubuntu 24+)
# First check if the version of AppArmor running on the device supports our profile.
# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0.
# In that case, we just skip installing the profile since the app runs fine without it on 22.04.
#
# Those apparmor_parser flags are akin to performing a dry run of loading a profile.
# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles
#
# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility.
# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile
if apparmor_status --enabled > /dev/null 2>&1; then
APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile'
APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}'
if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then
cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET"
# Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment.
# Use cases are for example environments where images for clients are maintained.
# There, AppArmor might correctly be installed, but live updating makes no sense.
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
# Extra flags taken from dh_apparmor:
# > By using '-W -T' we ensure that any abstraction updates are also pulled in.
# https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport
apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET"
fi
else
echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile"
fi
fi

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Delete the link to the binary
# update-alternatives --remove <name> <path>: 'path' must be the registered alternative binary,
# not the generic symlink — see https://man7.org/linux/man-pages/man1/update-alternatives.1.html
if type update-alternatives >/dev/null 2>&1; then
update-alternatives --remove '${executable}' '/opt/${sanitizedProductName}/${executable}'
else
rm -f '/usr/bin/${executable}'
fi
APPARMOR_PROFILE_DEST='/etc/apparmor.d/${executable}'
# Remove and unload apparmor profile.
if [ -f "$APPARMOR_PROFILE_DEST" ]; then
# Unload the profile from the running kernel before deleting the file so the
# policy is not left enforced until the next reboot. Mirror the chroot guard
# used in the after-install script — live AppArmor operations are not
# meaningful inside a chroot.
# https://wiki.debian.org/AppArmor/HowToUse
if apparmor_status --enabled > /dev/null 2>&1; then
if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then
apparmor_parser --remove "$APPARMOR_PROFILE_DEST" || true
fi
fi
rm -f "$APPARMOR_PROFILE_DEST"
fi
if hash gtk-update-icon-cache 2>/dev/null; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi

View File

@@ -0,0 +1,206 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const zlib = require("node:zlib");
const { VALID_VARIANTS } = require("../electron/bridges/appIconManager.cjs");
const APP_ICON_VARIANTS = [...VALID_VARIANTS];
function paethPredictor(left, up, upperLeft) {
const estimate = left + up - upperLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upperLeftDistance = Math.abs(estimate - upperLeft);
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
if (upDistance <= upperLeftDistance) return up;
return upperLeft;
}
function readRgbaPng(png, label) {
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
let offset = 8;
let width;
let height;
const imageData = [];
while (offset < png.length) {
const length = png.readUInt32BE(offset);
const type = png.subarray(offset + 4, offset + 8).toString("ascii");
const data = png.subarray(offset + 8, offset + 8 + length);
offset += length + 12;
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
assert.deepEqual(
[...data.subarray(8, 13)],
[8, 6, 0, 0, 0],
`${label} must be an 8-bit, non-interlaced RGBA PNG`,
);
} else if (type === "IDAT") {
imageData.push(data);
} else if (type === "IEND") {
break;
}
}
const bytesPerPixel = 4;
const stride = width * bytesPerPixel;
const raw = zlib.inflateSync(Buffer.concat(imageData));
assert.equal(raw.length, (stride + 1) * height, `${label} has unexpected PNG data`);
let sourceOffset = 0;
let previous = Buffer.alloc(stride);
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
const rows = [];
for (let y = 0; y < height; y += 1) {
const filter = raw[sourceOffset];
sourceOffset += 1;
const current = Buffer.from(raw.subarray(sourceOffset, sourceOffset + stride));
sourceOffset += stride;
for (let index = 0; index < stride; index += 1) {
const left = index >= bytesPerPixel ? current[index - bytesPerPixel] : 0;
const up = previous[index];
const upperLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] : 0;
let predictor;
if (filter === 0) predictor = 0;
else if (filter === 1) predictor = left;
else if (filter === 2) predictor = up;
else if (filter === 3) predictor = Math.floor((left + up) / 2);
else if (filter === 4) predictor = paethPredictor(left, up, upperLeft);
else assert.fail(`${label} uses unsupported PNG filter ${filter}`);
current[index] = (current[index] + predictor) & 0xff;
}
for (let x = 0; x < width; x += 1) {
if (current[x * bytesPerPixel + 3] <= 8) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
rows.push(current);
previous = current;
}
return { width, height, minX, minY, maxX, maxY, rows };
}
function readRgbaPngAlphaBounds(file) {
const image = readRgbaPng(fs.readFileSync(file), file);
assert.equal(image.width, 1024, `${file} must keep the 1024px app-icon canvas`);
assert.equal(image.height, 1024, `${file} must keep the 1024px app-icon canvas`);
return {
minX: image.minX,
minY: image.minY,
maxX: image.maxX,
maxY: image.maxY,
};
}
function readIcnsEntry(file, expectedType) {
const icns = fs.readFileSync(file);
assert.equal(icns.subarray(0, 4).toString("ascii"), "icns", `${file} must be ICNS`);
assert.equal(icns.readUInt32BE(4), icns.length, `${file} has an invalid ICNS length`);
for (let offset = 8; offset < icns.length;) {
const type = icns.subarray(offset, offset + 4).toString("ascii");
const length = icns.readUInt32BE(offset + 4);
assert.ok(length >= 8 && offset + length <= icns.length, `${file} has an invalid ${type} entry`);
if (type === expectedType) return icns.subarray(offset + 8, offset + length);
offset += length;
}
assert.fail(`${file} is missing the ${expectedType} representation`);
}
function assertSmallIcnsRepresentation(file, type, expectedPixels) {
const image = readRgbaPng(readIcnsEntry(file, type), `${file}:${type}`);
assert.equal(image.width, expectedPixels);
assert.equal(image.height, expectedPixels);
let unexpectedGreenPixels = 0;
for (const row of image.rows) {
for (let x = 0; x < image.width; x += 1) {
const offset = x * 4;
const [red, green, blue, alpha] = row.subarray(offset, offset + 4);
if (alpha > 8 && green > red + 20 && green > blue + 20) {
unexpectedGreenPixels += 1;
}
}
}
assert.equal(
unexpectedGreenPixels,
0,
`${file}:${type} contains green noise instead of the navy/white app artwork`,
);
}
test("main process leaves macOS Dock icon to the packaged app bundle", () => {
const mainProcess = fs.readFileSync(
path.join(__dirname, "../electron/main.cjs"),
"utf8",
);
assert.equal(
mainProcess.includes("app.dock.setIcon"),
false,
"Do not override the macOS Dock icon at runtime; it can render at a different size than the bundled .icns icon.",
);
});
test("macOS packages a native ICNS and sizes runtime Dock icons separately", () => {
const projectRoot = path.join(__dirname, "..");
const config = require("../electron-builder.config.cjs");
assert.equal(config.mac?.icon ?? config.icon, "build/icon.icns");
const packagedIcon = path.join(projectRoot, "build/icon.icns");
assert.ok(readIcnsEntry(packagedIcon, "ic04").length > 0);
assert.ok(readIcnsEntry(packagedIcon, "ic05").length > 0);
assertSmallIcnsRepresentation(packagedIcon, "ic11", 32);
assertSmallIcnsRepresentation(packagedIcon, "ic12", 64);
const generator = fs.readFileSync(
path.join(projectRoot, "scripts/generate-mac-icon.sh"),
"utf8",
);
assert.match(generator, /SMALL_VECTOR/);
assert.match(generator, /x="104\\\.0" y="104\\\.0"/);
assert.deepEqual(
readRgbaPngAlphaBounds(path.join(projectRoot, "public/icon.png")),
{ minX: 61, minY: 61, maxX: 962, maxY: 962 },
"The packaged icon already looks correct when Netcatty is not running",
);
for (const variant of APP_ICON_VARIANTS) {
const iconFile = path.join(
projectRoot,
"public/icons/variants/macos",
`${variant}.png`,
);
assert.deepEqual(
readRgbaPngAlphaBounds(iconFile),
{ minX: 100, minY: 100, maxX: 923, maxY: 923 },
`${path.relative(projectRoot, iconFile)} must render on the 824px macOS icon grid`,
);
}
});
test("non-macOS runtime icons preserve their existing desktop sizing", () => {
const projectRoot = path.join(__dirname, "..");
assert.deepEqual(
readRgbaPngAlphaBounds(path.join(projectRoot, "public/icon-win.png")),
{ minX: 0, minY: 0, maxX: 1023, maxY: 1023 },
"The packaged Windows icon must remain full bleed",
);
for (const variant of APP_ICON_VARIANTS) {
const iconFile = path.join(projectRoot, "public/icons/variants", `${variant}.png`);
assert.deepEqual(
readRgbaPngAlphaBounds(iconFile),
{ minX: 61, minY: 61, maxX: 962, maxY: 962 },
`${path.relative(projectRoot, iconFile)} must keep the existing desktop runtime size`,
);
}
});

View File

@@ -0,0 +1,49 @@
// 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");
function requestedArch() {
return process.env.npm_config_arch || process.env.npm_config_target_arch || process.arch;
}
function hasFile(file) {
return fs.existsSync(file) && fs.statSync(file).isFile();
}
function moshExtraResources(platform) {
const moshRoot = path.resolve(process.cwd(), "resources", "mosh");
if (!fs.existsSync(moshRoot)) return [];
if (platform === "darwin") {
const file = path.join(moshRoot, "darwin-universal", "mosh-client");
if (!hasFile(file)) return [];
return [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
];
}
if (platform === "linux") {
const arch = requestedArch();
const file = path.join(moshRoot, `linux-${arch}`, "mosh-client");
if (!hasFile(file)) return [];
return [
{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] },
];
}
if (platform === "win32") {
const arch = requestedArch();
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
if (!hasFile(exe)) return [];
return [
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
];
}
return [];
}
module.exports = { moshExtraResources };

View File

@@ -0,0 +1,73 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { moshExtraResources } = require("./mosh-extra-resources.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resources-"));
t.after(() => {
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
function withCwdAndArch(t, cwd, arch) {
const oldCwd = process.cwd();
const oldArch = process.env.npm_config_arch;
process.chdir(cwd);
process.env.npm_config_arch = arch;
t.after(() => {
process.chdir(oldCwd);
if (oldArch === undefined) delete process.env.npm_config_arch;
else process.env.npm_config_arch = oldArch;
});
}
function writeFile(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "x");
}
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, [
{ from: "resources/mosh/linux-x64/", to: "mosh/", filter: ["mosh-client"] },
]);
});
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"));
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("darwin");
assert.deepEqual(got, [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
]);
});
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"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
]);
process.env.npm_config_arch = "arm64";
assert.deepEqual(moshExtraResources("win32"), []);
});

View File

@@ -0,0 +1,92 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const ELECTRON_BUILDER_ARCH = {
0: "ia32",
1: "x64",
2: "armv7l",
3: "arm64",
4: "universal",
};
function nodePtyArtifacts(projectDir) {
const releaseDir = path.join(projectDir, "node_modules", "node-pty", "build", "Release");
return [
{ source: path.join(releaseDir, "conpty.node"), relative: "conpty.node" },
{ source: path.join(releaseDir, "conpty", "conpty.dll"), relative: path.join("conpty", "conpty.dll") },
{ source: path.join(releaseDir, "conpty", "OpenConsole.exe"), relative: path.join("conpty", "OpenConsole.exe") },
];
}
function rebuildPatchedNodePty({
projectDir,
platform,
arch,
run = execFileSync,
exists = fs.existsSync,
logger = console,
}) {
if (platform !== "win32") return false;
const targetArch = typeof arch === "number" ? ELECTRON_BUILDER_ARCH[arch] : arch;
if (!targetArch || targetArch === "universal") {
throw new Error(`[nodePtyConptyPatch] Unsupported Windows architecture: ${String(arch)}`);
}
const rebuildCli = path.join(projectDir, "node_modules", "@electron", "rebuild", "lib", "cli.js");
logger.log(`[nodePtyConptyPatch] Rebuilding patched node-pty for Windows ${targetArch}`);
run(process.execPath, [
rebuildCli,
"--force",
"--build-from-source",
"--only",
"node-pty",
"--arch",
targetArch,
], {
cwd: projectDir,
stdio: "inherit",
});
const nodePtyDir = path.join(projectDir, "node_modules", "node-pty");
run(process.execPath, [path.join(nodePtyDir, "scripts", "post-install.js")], {
cwd: projectDir,
stdio: "inherit",
env: { ...process.env, npm_config_arch: targetArch },
});
const missingArtifacts = nodePtyArtifacts(projectDir)
.map(({ source }) => source)
.filter((filePath) => !exists(filePath));
if (missingArtifacts.length > 0) {
throw new Error(
`[nodePtyConptyPatch] Patched node-pty artifacts missing: ${missingArtifacts.join(", ")}`,
);
}
return true;
}
function copyPatchedNodePtyToPackagedApp({ projectDir, resourcesDir, copy = fs.copyFileSync, mkdir = fs.mkdirSync }) {
const packagedReleaseDir = path.join(
resourcesDir,
"app.asar.unpacked",
"node_modules",
"node-pty",
"build",
"Release",
);
const copied = [];
for (const artifact of nodePtyArtifacts(projectDir)) {
const destination = path.join(packagedReleaseDir, artifact.relative);
mkdir(path.dirname(destination), { recursive: true });
copy(artifact.source, destination);
copied.push(destination);
}
return copied;
}
module.exports = {
copyPatchedNodePtyToPackagedApp,
nodePtyArtifacts,
rebuildPatchedNodePty,
};

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_XTERM_VERSION = "6.1.0-beta.292";
const FORCE_SELECTION_OPTION = "macOptionClickForcesSelection";
const META_OPTION = "macOptionIsMeta".padEnd(FORCE_SELECTION_OPTION.length, " ");
// xterm normally treats macOptionClickForcesSelection and column selection as
// mutually exclusive. Netcatty needs both when Option is not Meta: Option must
// keep forcing local selection inside mouse-aware programs, and that local
// selection must retain the standard macOS rectangular shape. Patch the pinned
// bundles at install time and fail closed if upstream changes the expected code
// shape.
const PATCHES = [
{
file: "node_modules/@xterm/xterm/lib/xterm.js",
original: "!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)",
replacement: `!(f.isMac&&this._optionsService.rawOptions.${META_OPTION})`,
preserveLength: true,
},
{
file: "node_modules/@xterm/xterm/lib/xterm.mjs",
original: "!(ie&&this._optionsService.rawOptions.macOptionClickForcesSelection)",
replacement: `!(ie&&this._optionsService.rawOptions.${META_OPTION})`,
preserveLength: true,
},
{
file: "node_modules/@xterm/xterm/src/browser/services/SelectionService.ts",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: `return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.${META_OPTION});`,
preserveStatementLength: true,
},
{
file: "node_modules/@xterm/xterm/lib/xterm.js.map",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: `return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.${META_OPTION});`,
preserveStatementLength: true,
},
{
file: "node_modules/@xterm/xterm/lib/xterm.mjs.map",
original: "return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection);",
replacement: `return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.${META_OPTION});`,
preserveStatementLength: true,
},
];
function sameLengthExpression(expression, length) {
if (expression.length > length) throw new Error("replacement expression is too long");
// Keep generated bundle offsets stable so the shipped source maps remain valid.
return `${expression}${" ".repeat(length - expression.length)}`;
}
function sameLengthStatement(statement, length) {
if (!statement.endsWith(";") || statement.length > length) {
throw new Error("replacement statement cannot preserve the target length");
}
return `${statement.slice(0, -1)}${" ".repeat(length - statement.length)};`;
}
function replacementForPatch(patch) {
if (!patch.replacement) throw new Error(`missing replacement for ${patch.file}`);
if (patch.preserveLength) {
return sameLengthExpression(patch.replacement, patch.original.length);
}
if (patch.preserveStatementLength) {
return sameLengthStatement(patch.replacement, patch.original.length);
}
return patch.replacement;
}
function patchXtermSource(source, patch) {
const replacement = replacementForPatch(patch);
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) {
return { source, changed: false };
}
throw new Error(
`${patch.file}: expected exactly one original or patched selection predicate ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
/**
* @param {string} root
* @param {Pick<typeof fs, "rmSync">} fsImpl
*/
function invalidateViteCache(root = process.cwd(), fsImpl = fs) {
const cachePath = path.resolve(root, "node_modules/.vite");
fsImpl.rmSync(cachePath, { recursive: true, force: true });
return cachePath;
}
function patchInstalledXterm(root = process.cwd()) {
const packageJsonPath = path.resolve(root, "node_modules/@xterm/xterm/package.json");
const version = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).version;
if (version !== EXPECTED_XTERM_VERSION) {
throw new Error(
`unsupported @xterm/xterm version ${version}; expected ${EXPECTED_XTERM_VERSION}`,
);
}
let changed = 0;
for (const patch of PATCHES) {
const absolutePath = path.resolve(root, patch.file);
const source = fs.readFileSync(absolutePath, "utf8");
const result = patchXtermSource(source, patch);
if (result.changed) {
fs.writeFileSync(absolutePath, result.source);
changed++;
}
}
const viteCachePath = invalidateViteCache(root);
return { changed, checked: PATCHES.length, version, viteCachePath };
}
if (require.main === module) {
try {
const result = patchInstalledXterm();
console.log(
`[patch-xterm-macos-column-selection] version=${result.version} ` +
`changed=${result.changed} checked=${result.checked} vite-cache=invalidated`,
);
} catch (error) {
console.error(`[patch-xterm-macos-column-selection] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = {
EXPECTED_XTERM_VERSION,
PATCHES,
invalidateViteCache,
patchInstalledXterm,
patchXtermSource,
replacementForPatch,
sameLengthExpression,
sameLengthStatement,
};

View File

@@ -0,0 +1,86 @@
"use strict";
const assert = require("node:assert/strict");
const path = require("node:path");
const test = require("node:test");
const {
PATCHES,
invalidateViteCache,
patchXtermSource,
replacementForPatch,
sameLengthExpression,
} = require("./patch-xterm-macos-column-selection.cjs");
test("patches both distributed bundles without shifting source-map offsets", () => {
for (const patch of PATCHES.filter((entry) => entry.preserveLength)) {
const input = `before:${patch.original}:after`;
const result = patchXtermSource(input, patch);
assert.equal(result.changed, true);
assert.equal(result.source.length, input.length);
assert.equal(result.source.includes(patch.original), false);
assert.equal(
result.source.includes(sameLengthExpression(patch.replacement, patch.original.length)),
true,
);
}
});
test("patches readable xterm sources and source-map content", () => {
for (const patch of PATCHES.filter((entry) => !entry.preserveLength)) {
const input = `before:${patch.original}:after`;
const result = patchXtermSource(input, patch);
assert.equal(result.changed, true);
assert.equal(result.source, `before:${replacementForPatch(patch)}:after`);
assert.equal(result.source.length, input.length);
}
});
test("keeps source-map token columns stable inside every patched expression", () => {
for (const patch of PATCHES) {
const replacement = replacementForPatch(patch);
for (const token of ["this", "_optionsService", "rawOptions", ")", ";"]) {
assert.equal(
replacement.lastIndexOf(token),
patch.original.lastIndexOf(token),
`${patch.file}: ${token} moved to a different generated column`,
);
}
}
});
test("is idempotent and fails closed on an unknown package shape", () => {
for (const patch of PATCHES) {
const replacement = replacementForPatch(patch);
assert.deepEqual(patchXtermSource(`before:${replacement}:after`, patch), {
source: `before:${replacement}:after`,
changed: false,
});
assert.throws(
() => patchXtermSource("unrecognized source", patch),
/expected exactly one original or patched selection predicate/,
);
}
});
test("fails closed when a generated-bundle replacement would shift offsets", () => {
assert.throws(
() => sameLengthExpression("replacement is longer", 4),
/replacement expression is too long/,
);
});
test("invalidates Vite's optimized dependency cache after patching xterm", () => {
const calls = [];
const cachePath = invalidateViteCache("/repo", {
rmSync(target, options) {
calls.push({ target, options });
},
});
assert.equal(cachePath, path.resolve("/repo/node_modules/.vite"));
assert.deepEqual(calls, [{
target: cachePath,
options: { recursive: true, force: true },
}]);
});

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env node
/* global process, console */
/**
* Verify @xterm/addon-webgl carries the upstream glyph-atlas safety fixes.
*
* Netcatty previously backported these as local string patches. With
* @xterm/addon-webgl >= 0.20.0-beta.291 they ship upstream:
*
* - xtermjs/xterm.js#6055 — shared atlas: clearTexture bumps _pageLayoutVersion
* - xtermjs/xterm.js#5987 — no generateMipmap on atlas upload (LINEAR filters)
* - xtermjs/xterm.js#6043 — _evictAllPages / maxAtlasPages capacity handling
*
* This script no longer mutates node_modules. It only fails closed when the
* pinned package is missing any of the above, so a silent downgrade cannot
* reintroduce garbled split-pane / mipmap / overflow bugs.
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const TARGET_FILES = [
"node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
"node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
];
/** Upstream #6055: clearTexture bumps page layout version so shared atlases rebuild. */
function hasUpstreamSharedAtlasClearFix(source) {
return (
source.includes("_pageLayoutVersion") &&
/clearTexture\(\)\{[^}]*_pageLayoutVersion\+\+/.test(source)
);
}
/** Upstream #5987: atlas texture upload must not call generateMipmap. */
function hasUpstreamMipmapFix(source) {
return !source.includes(".generateMipmap(");
}
/** Upstream #6043: capacity eviction when atlas pages overflow texture slots. */
function hasUpstreamCapacityFix(source) {
return source.includes("_evictAllPages()") && source.includes("maxAtlasPages");
}
let webglVersion = "";
try {
const packageJson = path.resolve(
process.cwd(),
"node_modules/@xterm/addon-webgl/package.json",
);
webglVersion = JSON.parse(fs.readFileSync(packageJson, "utf8")).version || "";
} catch {
// Handled below when files are missing.
}
const results = { ok: 0, missing: 0 };
for (const file of TARGET_FILES) {
const abs = path.resolve(process.cwd(), file);
let source;
try {
source = fs.readFileSync(abs, "utf8");
} catch {
console.warn(`[patch-xterm-webgl-atlas] ERROR: not found: ${file}`);
results.missing++;
continue;
}
const checks = [
{
name: "shared-atlas clear (#6055)",
ok: hasUpstreamSharedAtlasClearFix(source),
hint: "need clearTexture() { ... _pageLayoutVersion++ }",
},
{
name: "no atlas mipmaps (#5987)",
ok: hasUpstreamMipmapFix(source),
hint: "must not call gl.generateMipmap on atlas upload",
},
{
name: "atlas capacity eviction (#6043)",
ok: hasUpstreamCapacityFix(source),
hint: "need _evictAllPages() and maxAtlasPages",
},
];
const failed = checks.filter((check) => !check.ok);
if (failed.length === 0) {
results.ok++;
continue;
}
results.missing++;
for (const check of failed) {
console.warn(
`[patch-xterm-webgl-atlas] ERROR: missing ${check.name} in ${file} ` +
`(${check.hint}). Upgrade @xterm/addon-webgl ` +
`(current: ${webglVersion || "unknown"}).`,
);
}
}
console.log(
`[patch-xterm-webgl-atlas] verify version=${webglVersion || "unknown"} ` +
`ok=${results.ok} missing=${results.missing}`,
);
if (results.missing > 0) process.exitCode = 1;

View File

@@ -0,0 +1,122 @@
/* global __dirname, process */
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const script = path.resolve(__dirname, "patch-xterm-webgl-atlas.cjs");
/** Minimal stand-in for upstream #6055 + #5987 + #6043 (beta.291+). */
function upstreamAllFixedSource() {
return (
`for(let c=0;c<e0.length;c++){let u=e0[c];if(Ee(u.config,h))return u.ownedBy.push(i),u.atlas} ` +
`clearTexture(){if(!(this._pages[0].currentRow.x===0&&this._pages[0].currentRow.y===0)){` +
`for(let e of this._pages)e.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),` +
`this._didWarmUp=!1,this._pageLayoutVersion++} ` +
`_evictAllPages(){this._pages.length=0} maxAtlasPages ` +
`t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR),` +
`t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),` +
`t.texImage2D(t.TEXTURE_2D,0,t.RGBA,t.RGBA,t.UNSIGNED_BYTE,r.pages[n].canvas),` +
`this._atlasTextures[n].version=r.pages[n].version`
);
}
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-webgl-patch-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
function writeWebglVersion(root, version) {
const packageJson = path.join(root, "node_modules/@xterm/addon-webgl/package.json");
fs.mkdirSync(path.dirname(packageJson), { recursive: true });
fs.writeFileSync(packageJson, JSON.stringify({ version }));
}
function writeRawWebglBuild(root, file, source) {
const abs = path.join(root, file);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
function writeBothBuilds(root, source) {
writeRawWebglBuild(root, "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs", source);
writeRawWebglBuild(root, "node_modules/@xterm/addon-webgl/lib/addon-webgl.js", source);
}
test("accepts packages that ship upstream #6055/#5987/#6043", async (t) => {
const root = makeTmp(t);
writeWebglVersion(root, "0.20.0-beta.291");
writeBothBuilds(root, upstreamAllFixedSource());
const { stdout, stderr } = await execFileAsync(process.execPath, [script], { cwd: root });
assert.match(stdout, /verify version=0\.20\.0-beta\.291 ok=2 missing=0/);
assert.equal(stderr, "");
// Verify mode must not rewrite node_modules.
assert.equal(
fs.readFileSync(path.join(root, "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs"), "utf8"),
upstreamAllFixedSource(),
);
});
test("fails closed when shared-atlas clear fix is missing", async (t) => {
const root = makeTmp(t);
writeWebglVersion(root, "0.20.0-beta.219");
// Capacity + no mipmap, but no pageLayoutVersion on clearTexture.
writeBothBuilds(
root,
`clearTexture(){this._pages[0].clear()} _evictAllPages(){} maxAtlasPages LINEAR`,
);
await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => {
assert.equal(error.code, 1);
assert.match(error.stderr, /shared-atlas clear \(#6055\)/);
assert.match(error.stdout, /ok=0 missing=2/);
return true;
});
});
test("fails closed when generateMipmap is still present", async (t) => {
const root = makeTmp(t);
writeWebglVersion(root, "0.20.0-beta.291");
writeBothBuilds(
root,
`clearTexture(){this._pageLayoutVersion++} _evictAllPages(){} maxAtlasPages ` +
`gl.generateMipmap(gl.TEXTURE_2D)`,
);
await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => {
assert.equal(error.code, 1);
assert.match(error.stderr, /no atlas mipmaps \(#5987\)/);
return true;
});
});
test("fails closed when capacity eviction is missing", async (t) => {
const root = makeTmp(t);
writeWebglVersion(root, "0.20.0-beta.291");
writeBothBuilds(root, `clearTexture(){this._pageLayoutVersion++}`);
await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => {
assert.equal(error.code, 1);
assert.match(error.stderr, /atlas capacity eviction \(#6043\)/);
return true;
});
});
test("fails closed when addon builds are missing", async (t) => {
const root = makeTmp(t);
writeWebglVersion(root, "0.20.0-beta.291");
await assert.rejects(execFileAsync(process.execPath, [script], { cwd: root }), (error) => {
assert.equal(error.code, 1);
assert.match(error.stderr, /not found/);
assert.match(error.stdout, /ok=0 missing=2/);
return true;
});
});

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env bash
set -euo pipefail
input_path="${1:?input path is required}"
research_dir="${2:?research directory is required}"
attachment_urls_path="$research_dir/attachment-urls.json"
node -e '
const fs = require("node:fs");
const auto = require(process.env.RUNNER_TEMP + "/ai-automation.cjs");
const input = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const urls = auto.extractGithubUserAttachmentAssetUrls(input);
fs.writeFileSync(process.argv[2], JSON.stringify(urls) + "\n");
' "$input_path" "$attachment_urls_path"
attachment_count="$(node -p 'require(process.argv[1]).length' "$attachment_urls_path")"
if (( attachment_count > 4 )); then
echo "Too many GitHub image attachments for bounded research: ${attachment_count}" >&2
exit 1
fi
if (( attachment_count == 0 )); then
cp "$input_path" "$research_dir/input.json"
exit 0
fi
mkdir -p "$research_dir/attachments"
attachment_kinds_path="$research_dir/attachment-kinds.txt"
: > "$attachment_kinds_path"
image_count=0
for (( index=0; index<attachment_count; index++ )); do
source_url="$(node -e '
process.stdout.write(require(process.argv[1])[Number(process.argv[2])]);
' "$attachment_urls_path" "$index")"
headers_path="$(mktemp "${RUNNER_TEMP}/ai-attachment-headers.XXXXXX")"
curl --proto '=https' --tlsv1.2 --retry 3 --retry-all-errors \
--connect-timeout 3 --max-time 20 --max-redirs 0 -fsS \
-D "$headers_path" -o /dev/null "$source_url"
kind="$(node -e '
const fs = require("node:fs");
const auto = require(process.env.RUNNER_TEMP + "/ai-automation.cjs");
process.stdout.write(auto.classifyGithubUserAttachmentRedirect(
fs.readFileSync(process.argv[1], "utf8"),
));
' "$headers_path")"
rm -f "$headers_path"
if [[ "$kind" != "image" && "$kind" != "unsupported_media" ]]; then
echo "GitHub attachment did not provide a trusted image/video/audio redirect." >&2
exit 1
fi
printf '%s\n' "$kind" >> "$attachment_kinds_path"
if [[ "$kind" == "image" ]]; then
image_count=$((image_count + 1))
fi
done
container_name="ai-research-imgproxy-${GITHUB_RUN_ID}-${GITHUB_JOB}"
if (( image_count > 0 )); then
docker run -d --rm --name "$container_name" \
--cap-drop=ALL --security-opt=no-new-privileges --read-only \
--memory=512m --cpus=1 --pids-limit=128 \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-p 127.0.0.1:18081:8080 \
-e IMGPROXY_ALLOWED_SOURCES=https://github.com/user-attachments/assets/ \
-e IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES=false \
-e IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES=false \
-e IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES=false \
-e IMGPROXY_MAX_SRC_FILE_SIZE=10485760 \
-e IMGPROXY_MAX_SRC_RESOLUTION=50 \
-e IMGPROXY_MAX_RESULT_DIMENSION=4096 \
-e IMGPROXY_MAX_REDIRECTS=2 \
-e IMGPROXY_MAX_ANIMATION_FRAMES=1 \
-e IMGPROXY_ALWAYS_RASTERIZE_SVG=true \
-e IMGPROXY_ALLOW_SECURITY_OPTIONS=false \
-e IMGPROXY_COOKIE_PASSTHROUGH=false \
-e IMGPROXY_COOKIE_PASSTHROUGH_ALL=false \
"$AI_RESEARCH_IMGPROXY_IMAGE" >/dev/null
fi
stop_imgproxy() {
if (( image_count > 0 )); then
docker stop "$container_name" >/dev/null 2>&1 || true
fi
}
trap stop_imgproxy EXIT
for (( index=0; index<attachment_count; index++ )); do
kind="$(sed -n "$((index + 1))p" "$attachment_kinds_path")"
if [[ "$kind" != "image" ]]; then
continue
fi
source_url="$(node -e '
process.stdout.write(require(process.argv[1])[Number(process.argv[2])]);
' "$attachment_urls_path" "$index")"
encoded_source="$(node -e '
process.stdout.write(Buffer.from(process.argv[1]).toString("base64url"));
' "$source_url")"
output_path="$research_dir/attachments/issue-image-$((index + 1)).png"
curl --retry 8 --retry-all-errors --retry-delay 1 --retry-max-time 45 \
--connect-timeout 3 --max-time 30 -fsS \
"http://127.0.0.1:18081/unsafe/${encoded_source}.png" \
-o "$output_path"
node -e '
const fs = require("node:fs");
const expected = Buffer.from("89504e470d0a1a0a", "hex");
const actual = fs.readFileSync(process.argv[1]).subarray(0, expected.length);
if (!actual.equals(expected)) throw new Error("imgproxy did not produce PNG output");
' "$output_path"
done
stop_imgproxy
trap - EXIT
# The single-quoted program intentionally contains JavaScript template syntax.
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const auto = require(process.env.RUNNER_TEMP + "/ai-automation.cjs");
const input = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const urls = require(process.argv[2]);
const kinds = fs.readFileSync(process.argv[3], "utf8").trim().split("\n");
const attachments = urls.map((sourceUrl, index) => ({
sourceUrl,
kind: kinds[index],
...(kinds[index] === "image"
? { relativePath: `attachments/issue-image-${index + 1}.png` }
: {}),
}));
fs.writeFileSync(
process.argv[4],
JSON.stringify(auto.rewriteExternalResearchInputAttachments(input, attachments), null, 2) + "\n",
);
' "$input_path" "$attachment_urls_path" "$attachment_kinds_path" "$research_dir/input.json"
rm -f "$attachment_urls_path" "$attachment_kinds_path"

View File

@@ -0,0 +1,7 @@
const { rebuildPatchedNodePty } = require("./nodePtyConptyPatch.cjs");
rebuildPatchedNodePty({
projectDir: process.cwd(),
platform: process.platform,
arch: process.env.npm_config_arch || process.arch,
});

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
/* eslint-disable no-console */
//
// Resolve the EternalTerminal `et` client binary release used by
// build-packages.
//
// Priority:
// 1. ET_BIN_RELEASE from workflow input / repository variable.
// 2. Latest non-draft, non-prerelease GitHub Release whose tag is
// et-bin-* in ET_BIN_OWNER/ET_BIN_REPO. By default this is a
// dedicated sibling binary repository named Netcatty-et-bin.
//
// In GitHub Actions, the resolved tag is written back to $GITHUB_ENV so
// later steps can run scripts/fetch-et-binaries.cjs without duplicating
// release discovery logic.
const fs = require("node:fs");
const https = require("node:https");
const TAG_RE = /^et-bin-[A-Za-z0-9._-]+$/;
function log(msg) {
console.log(`[resolve-et-bin-release] ${msg}`);
}
function validateReleaseTag(tag) {
const value = String(tag || "").trim();
if (!TAG_RE.test(value)) {
throw new Error(`invalid et binary release tag: ${tag}`);
}
return value;
}
function parseRepository(env) {
const owner = env.ET_BIN_OWNER || (env.GITHUB_REPOSITORY || "").split("/")[0] || "binaricat";
const repo = env.ET_BIN_REPO || "Netcatty-et-bin";
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 pickLatestEtBinRelease(releases) {
return releases
.map((release, index) => ({ release, index }))
.filter(({ release }) => {
return release
&& TAG_RE.test(String(release.tag_name || ""))
&& 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 et binary releases"));
return;
}
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "netcatty-et-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.ET_BIN_RELEASES_JSON) {
const parsed = JSON.parse(env.ET_BIN_RELEASES_JSON);
if (!Array.isArray(parsed)) {
throw new Error("ET_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 et-bin-* 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, `ET_BIN_RELEASE=${release}\n`, "utf8");
}
}
async function main(env = process.env) {
if (String(env.ET_BIN_RELEASE || "").trim()) {
const release = validateReleaseTag(env.ET_BIN_RELEASE);
exportRelease(release, env);
log(`using ET_BIN_RELEASE=${release}`);
return release;
}
const releases = await loadReleases(env);
const release = pickLatestEtBinRelease(releases);
if (!release) {
throw new Error(
"could not find a non-draft et-bin-* release in the et binary repository. Publish build-et-binaries artifacts with release_tag (for example et-bin-6.2.10-1) before packaging.",
);
}
const validated = validateReleaseTag(release);
exportRelease(validated, env);
log(`resolved ET_BIN_RELEASE=${validated}`);
return validated;
}
if (require.main === module) {
main().catch((err) => {
console.error(`[resolve-et-bin-release] FATAL ${err.message}`);
process.exit(1);
});
}
module.exports = {
loadReleases,
parseNextLink,
validateReleaseTag,
parseRepository,
pickLatestEtBinRelease,
main,
};

View File

@@ -0,0 +1,134 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
loadReleases,
main,
parseRepository,
parseNextLink,
pickLatestEtBinRelease,
validateReleaseTag,
} = require("./resolve-et-bin-release.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-resolve-et-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
test("validateReleaseTag accepts only et binary release tags", () => {
assert.equal(validateReleaseTag("et-bin-6.2.10-1"), "et-bin-6.2.10-1");
assert.throws(() => validateReleaseTag("v1.2.3"), /invalid et binary release tag/);
assert.throws(() => validateReleaseTag("et-bin-../bad"), /invalid et binary release tag/);
});
test("parseRepository falls back to the dedicated et binary repository", () => {
assert.deepEqual(parseRepository({}), { owner: "binaricat", repo: "Netcatty-et-bin" });
assert.deepEqual(parseRepository({ GITHUB_REPOSITORY: "owner/project" }), {
owner: "owner",
repo: "Netcatty-et-bin",
});
assert.deepEqual(
parseRepository({ GITHUB_REPOSITORY: "owner/project", ET_BIN_OWNER: "bin", ET_BIN_REPO: "binaries" }),
{ owner: "bin", repo: "binaries" },
);
});
test("pickLatestEtBinRelease ignores non-packaging releases", () => {
const got = pickLatestEtBinRelease([
{ tag_name: "v1.0.0", published_at: "2026-03-01T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-3", draft: true, published_at: "2026-04-01T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-4", prerelease: true, published_at: "2026-04-02T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-1", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-2", published_at: "2026-03-01T00:00:00Z" },
]);
assert.equal(got, "et-bin-6.2.10-2");
});
test("parseNextLink reads the next GitHub pagination URL", () => {
const link = [
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=1>; rel="prev"',
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=3>; rel="next"',
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=9>; rel="last"',
].join(", ");
assert.equal(
parseNextLink(link),
"https://api.github.com/repos/owner/repo/releases?per_page=100&page=3",
);
assert.equal(parseNextLink('<https://api.github.com/repos/owner/repo/releases?page=1>; rel="last"'), null);
});
test("loadReleases follows GitHub pagination until the last page", async () => {
const requested = [];
const got = await loadReleases({ GITHUB_REPOSITORY: "owner/repo" }, async (url) => {
requested.push(url);
if (url.includes("page=2")) {
return {
json: [{ tag_name: "et-bin-6.2.10-1", published_at: "2026-01-01T00:00:00Z" }],
headers: {},
};
}
return {
json: [{ tag_name: "v1.0.0", published_at: "2026-01-01T00:00:00Z" }],
headers: {
link: '<https://api.github.com/repos/owner/repo/releases?per_page=100&page=2>; rel="next"',
},
};
});
assert.deepEqual(got.map((release) => release.tag_name), ["v1.0.0", "et-bin-6.2.10-1"]);
assert.equal(requested.length, 2);
});
test("loadReleases rejects pagination loops", async () => {
await assert.rejects(
loadReleases({ GITHUB_REPOSITORY: "owner/repo" }, async (url) => ({
json: [],
headers: { link: `<${url}>; rel="next"` },
})),
/pagination looped/,
);
});
test("main keeps an explicit ET_BIN_RELEASE and exports it", async (t) => {
const githubEnv = path.join(makeTmp(t), "github-env");
const got = await main({
ET_BIN_RELEASE: "et-bin-6.2.10-1",
GITHUB_ENV: githubEnv,
});
assert.equal(got, "et-bin-6.2.10-1");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "ET_BIN_RELEASE=et-bin-6.2.10-1\n");
});
test("main resolves the latest release from the release list and exports it", async (t) => {
const githubEnv = path.join(makeTmp(t), "github-env");
const got = await main({
GITHUB_ENV: githubEnv,
ET_BIN_RELEASES_JSON: JSON.stringify([
{ tag_name: "et-bin-6.2.10-1", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-2", published_at: "2026-02-01T00:00:00Z" },
]),
});
assert.equal(got, "et-bin-6.2.10-2");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "ET_BIN_RELEASE=et-bin-6.2.10-2\n");
});
test("main fails when no usable release exists", async () => {
await assert.rejects(
main({
ET_BIN_RELEASES_JSON: JSON.stringify([
{ tag_name: "v1.0.0", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "et-bin-6.2.10-1", draft: true, published_at: "2026-02-01T00:00:00Z" },
]),
}),
/could not find/,
);
});

View File

@@ -0,0 +1,246 @@
#!/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,
};

View File

@@ -0,0 +1,184 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
loadReleases,
main,
parseRepository,
parseNextLink,
pickLatestMoshBinRelease,
validateReleaseTag,
isAtLeastMinRelease,
MIN_TAG,
} = require("./resolve-mosh-bin-release.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-resolve-mosh-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
test("validateReleaseTag accepts only moshcatty-* tags at min version", () => {
assert.equal(validateReleaseTag("moshcatty-0.1.8"), "moshcatty-0.1.8");
assert.equal(validateReleaseTag("moshcatty-0.2.0"), "moshcatty-0.2.0");
assert.equal(validateReleaseTag("moshcatty-0.1.9-rc1"), "moshcatty-0.1.9-rc1");
assert.equal(validateReleaseTag("moshcatty-0.1.8+build.1"), "moshcatty-0.1.8+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("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"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.3"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.4"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.5"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.6"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.7"), /below minimum/);
assert.throws(() => validateReleaseTag("moshcatty-0.1.8-rc1"), /below minimum/);
});
test("isAtLeastMinRelease enforces moshcatty-0.1.8 floor with semver prerelease rules", () => {
assert.equal(MIN_TAG, "moshcatty-0.1.8");
assert.equal(isAtLeastMinRelease("moshcatty-0.1.3"), false);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.4"), false);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.5"), false);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.6"), false);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.7"), false);
// Prerelease of the floor sorts below the final floor release.
assert.equal(isAtLeastMinRelease("moshcatty-0.1.8-rc1"), false);
// Above the floor, prereleases are fine.
assert.equal(isAtLeastMinRelease("moshcatty-0.1.9-rc1"), true);
assert.equal(isAtLeastMinRelease("moshcatty-0.1.8+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: "binaricat",
repo: "MoshCatty",
});
assert.deepEqual(
parseRepository({ GITHUB_REPOSITORY: "owner/project", MOSH_BIN_OWNER: "bin", MOSH_BIN_REPO: "binaries" }),
{ owner: "bin", repo: "binaries" },
);
});
test("pickLatestMoshBinRelease ignores non-moshcatty and pre-0.1.8 tags", () => {
const got = pickLatestMoshBinRelease([
{ tag_name: "v1.0.0", 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.8", draft: true, published_at: "2026-07-13T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", published_at: "2026-07-10T00:00:00Z" },
{ tag_name: "moshcatty-0.1.5", published_at: "2026-07-10T13:00:00Z" },
{ tag_name: "moshcatty-0.1.6", published_at: "2026-07-13T01:00:00Z" },
{ tag_name: "moshcatty-0.1.7", published_at: "2026-07-14T01:00:00Z" },
{ tag_name: "moshcatty-0.1.8", published_at: "2026-07-17T01:00:00Z" },
]);
assert.equal(got, "moshcatty-0.1.8");
});
test("parseNextLink reads the next GitHub pagination URL", () => {
const link = [
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=1>; rel="prev"',
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=3>; rel="next"',
'<https://api.github.com/repos/owner/repo/releases?per_page=100&page=9>; rel="last"',
].join(", ");
assert.equal(
parseNextLink(link),
"https://api.github.com/repos/owner/repo/releases?per_page=100&page=3",
);
assert.equal(parseNextLink('<https://api.github.com/repos/owner/repo/releases?page=1>; rel="last"'), null);
});
test("loadReleases follows GitHub pagination until the last page", async () => {
const requested = [];
const got = await loadReleases({ GITHUB_REPOSITORY: "owner/repo" }, async (url) => {
requested.push(url);
if (url.includes("page=2")) {
return {
json: [{ tag_name: "moshcatty-0.1.6", published_at: "2026-01-01T00:00:00Z" }],
headers: {},
};
}
return {
json: [{ tag_name: "v1.0.0", published_at: "2026-01-01T00:00:00Z" }],
headers: {
link: '<https://api.github.com/repos/owner/repo/releases?per_page=100&page=2>; rel="next"',
},
};
});
assert.deepEqual(got.map((release) => release.tag_name), ["v1.0.0", "moshcatty-0.1.6"]);
assert.equal(requested.length, 2);
});
test("loadReleases rejects pagination loops", async () => {
await assert.rejects(
loadReleases({ GITHUB_REPOSITORY: "owner/repo" }, async (url) => ({
json: [],
headers: { link: `<${url}>; rel="next"` },
})),
/pagination looped/,
);
});
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: "moshcatty-0.1.8",
GITHUB_ENV: githubEnv,
});
assert.equal(got, "moshcatty-0.1.8");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=moshcatty-0.1.8\n");
});
test("main rejects explicit pre-0.1.8 MOSH_BIN_RELEASE", async () => {
await assert.rejects(
main({ MOSH_BIN_RELEASE: "moshcatty-0.1.7" }),
/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: "moshcatty-0.1.0", published_at: "2026-01-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.2", published_at: "2026-07-10T00:00:00Z" },
{ tag_name: "moshcatty-0.1.5", published_at: "2026-07-10T13:00:00Z" },
{ tag_name: "moshcatty-0.1.6", published_at: "2026-07-13T01:00:00Z" },
{ tag_name: "moshcatty-0.1.7", published_at: "2026-07-14T01:00:00Z" },
{ tag_name: "moshcatty-0.1.8", published_at: "2026-07-17T01:00:00Z" },
{ tag_name: "mosh-bin-1.4.0-2", published_at: "2026-08-01T00:00:00Z" },
]),
});
assert.equal(got, "moshcatty-0.1.8");
assert.equal(fs.readFileSync(githubEnv, "utf8"), "MOSH_BIN_RELEASE=moshcatty-0.1.8\n");
});
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", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.5", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.6", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.7", published_at: "2026-02-01T00:00:00Z" },
{ tag_name: "moshcatty-0.1.8", draft: true, published_at: "2026-02-01T00:00:00Z" },
]),
}),
/could not find/,
);
});

View File

@@ -0,0 +1,184 @@
"use strict";
// Opt-in loopback SSH/SFTP fixture; never connects to a user's server.
// NETCATTY_SFTP_LIVE=1 SFTP_LIVE_MIB=128 SFTP_LIVE_FILES=12 node scripts/sftp-transfer-resume.live.test.cjs
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { Server } = require("ssh2");
const SftpClient = require("ssh2-sftp-client");
async function main() {
const tempBridgePath = require.resolve("../electron/bridges/tempDirBridge.cjs");
const managedTempBridge = require(tempBridgePath);
const root = fs.mkdtempSync(`${managedTempBridge.getTempFilePath("sftp-live")}-`);
try {
console.log("SFTP_LIVE_ROOT", root);
// Keep the fixture visible to managed cleanup, but isolate its staging and
// identity paths from the user's app. Reload only this process's cached
// bridge after changing the environment; never delete the managed parent.
for (const key of ["TMPDIR", "TMP", "TEMP", "HOME"]) process.env[key] = root;
delete require.cache[tempBridgePath];
await runFixture(root);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
async function runFixture(root) {
let bridge;
if (process.env.SFTP_LIVE_BASELINE_REF) {
const Module = require("node:module");
const filename = path.resolve(__dirname, "../electron/bridges/transferBridge.cjs");
const baseline = new Module(filename, module);
baseline.filename = filename;
baseline.paths = Module._nodeModulePaths(path.dirname(filename));
baseline._compile(require("node:child_process").execFileSync("git", ["show", `${process.env.SFTP_LIVE_BASELINE_REF}:electron/bridges/transferBridge.cjs`], { cwd: path.resolve(__dirname, ".."), encoding: "utf8" }), filename);
bridge = baseline.exports;
} else {
bridge = require("../electron/bridges/transferBridge.cjs");
}
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
assert.equal(path.dirname(tempDirBridge.getTempDir()), root, "fixture staging must remain isolated");
const bytes = Number(process.env.SFTP_LIVE_MIB || 8) * 1024 * 1024;
const fileCount = Number(process.env.SFTP_LIVE_FILES || 1);
const payload = Buffer.allocUnsafe(bytes);
for (let i = 0; i < bytes; i += 1) payload[i] = i % 251;
const digest = crypto.createHash("sha256").update(payload).digest("hex");
const privateKey = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey.export({ type: "pkcs1", format: "pem" });
const connections = new Set();
let reads = 0;
let activeReads = 0;
let peakReads = 0;
const server = new Server({ hostKeys: [privateKey] }, (connection) => {
connections.add(connection);
connection.on("error", () => {});
connection.on("close", () => connections.delete(connection));
connection.on("authentication", (context) => context.username === "fixture" ? context.accept() : context.reject());
connection.on("ready", () => connection.on("session", (accept) => {
const session = accept();
session.on("sftp", (acceptSftp) => {
const sftp = acceptSftp();
let nextHandle = 0;
const attrs = { size: bytes, mode: 0o100644, uid: 1, gid: 1, atime: 1700000000, mtime: 1700000000 };
sftp.on("error", () => {});
sftp.on("REALPATH", (id) => sftp.name(id, [{ filename: "/", longname: "/", attrs }]));
for (const operation of ["STAT", "LSTAT", "FSTAT"]) sftp.on(operation, (id) => sftp.attrs(id, attrs));
sftp.on("OPEN", (id) => sftp.handle(id, Buffer.from(String(nextHandle++))));
sftp.on("CLOSE", (id) => sftp.status(id, 0));
sftp.on("READ", (id, _handle, position, length) => {
reads += 1;
activeReads += 1;
peakReads = Math.max(peakReads, activeReads);
setTimeout(() => {
activeReads -= 1;
if (sftp.destroyed) return;
if (position >= bytes) sftp.status(id, 1);
else sftp.data(id, payload.subarray(position, Math.min(bytes, position + length)));
}, Number(process.env.SFTP_LIVE_DELAY_MS || 5));
});
});
}));
});
const client = new SftpClient();
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
await client.connect({ host: "127.0.0.1", port: server.address().port, username: "fixture", password: "fixture" });
bridge.init({ sftpClients: new Map([["source", client]]) });
const runFile = async (index) => {
const transferId = `live-${crypto.randomUUID()}`;
const targetPath = path.join(root, `output-${index}.bin`);
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
const checkpoint = Math.floor(bytes / 4);
fs.writeFileSync(stagePath, payload.subarray(0, checkpoint));
let verificationStart = 0;
let verificationMs = 0;
let requestedPause = false;
let pauseResult;
const startedAt = performance.now();
const running = bridge.startTransfer({ sender: { send(_channel, event) {
if (event.phase === "verifying" && !verificationStart) verificationStart = performance.now();
if (event.phase !== "verifying" && verificationStart) {
verificationMs += performance.now() - verificationStart;
verificationStart = 0;
}
if (!requestedPause && event.transferred > bytes / 2 && event.transferred < bytes && event.phase === "transferring") {
requestedPause = true;
const pauseStarted = performance.now();
pauseResult = bridge.pauseTransfer(null, { transferId }).then(async (result) => {
assert.equal(result.success, true, result.reason);
const pauseMs = performance.now() - pauseStarted;
const resumeStarted = performance.now();
const resumed = await bridge.resumeTransfer(null, { transferId });
assert.equal(resumed.success, true, resumed.reason);
return { pauseMs: Math.round(pauseMs), resumeMs: Math.round(performance.now() - resumeStarted) };
});
}
} } }, {
transferId, sourcePath: "/source.bin", targetPath,
sourceType: "sftp", targetType: "local", sourceSftpId: "source",
totalBytes: bytes, resumable: true, checkpointBytes: checkpoint,
sourceFingerprint: `sha256:p${bytes}:${digest}`,
});
const result = await running;
assert.equal(result.error, undefined, result.error);
const control = await pauseResult;
const outputDigest = crypto.createHash("sha256");
for await (const chunk of fs.createReadStream(targetPath)) outputDigest.update(chunk);
assert.equal(outputDigest.digest("hex"), digest);
fs.unlinkSync(targetPath);
return { index, bytes, elapsedMs: Math.round(performance.now() - startedAt), verificationMs: Math.round(verificationMs), ...control };
};
const results = [];
for (let index = 0; index < fileCount; index += 2) {
results.push(...await Promise.all(Array.from({ length: Math.min(2, fileCount - index) }, (_, offset) => runFile(index + offset))));
}
console.log("SFTP_LIVE_PASS", JSON.stringify({ results, reads, peakReads }));
} finally {
await client.end().catch(() => {});
for (const connection of connections) connection.end();
await new Promise((resolve) => server.close(resolve));
}
}
if (process.env.NETCATTY_SFTP_LIVE === "1") {
const watchdog = setTimeout(() => { console.error("SFTP_LIVE_FAIL timeout"); process.exit(1); }, 120_000);
main().catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => clearTimeout(watchdog));
} else {
const test = require("node:test");
for (const existingParent of [false, true]) {
test(`live SFTP fixture cleans setup failure with ${existingParent ? "existing" : "fresh"} managed parent`, (t) => {
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const testRoot = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("sftp-live-setup-test")}-`);
t.after(() => fs.rmSync(testRoot, { recursive: true, force: true }));
const managedParent = path.join(testRoot, "Netcatty");
if (existingParent) fs.mkdirSync(managedParent, { mode: 0o700 });
const result = require("node:child_process").spawnSync(process.execPath, [__filename], {
env: {
...process.env,
...Object.fromEntries(["TMPDIR", "TMP", "TEMP", "HOME"].map((key) => [key, testRoot])),
NETCATTY_SFTP_LIVE: "1", SFTP_LIVE_MIB: "-1", SFTP_LIVE_BASELINE_REF: "",
},
encoding: "utf8",
timeout: 10_000,
});
assert.equal(result.status, 1, result.stderr);
assert.match(result.stderr, /RangeError/);
const match = result.stdout.match(/^SFTP_LIVE_ROOT ([^\r\n]+)/m);
assert.ok(match, result.stdout);
const fixtureRoot = match[1];
assert.match(path.basename(fixtureRoot), /sftp-live/);
assert.equal(path.dirname(fixtureRoot), managedParent);
assert.equal(fs.existsSync(fixtureRoot), false);
assert.equal(fs.existsSync(path.dirname(fixtureRoot)), true, "must not delete the shared managed parent");
});
}
test("large-file SFTP resume over a loopback SSH connection", {
skip: "set NETCATTY_SFTP_LIVE=1 to run the real connection fixture",
}, () => {});
}

View File

@@ -0,0 +1,94 @@
"use strict";
const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
"OWNER",
"MEMBER",
"COLLABORATOR",
]);
const DANGEROUS_FILE_EXTENSION =
"(?:zip|7z|rar|tar\\.gz|tgz|exe|msi|dmg|pkg|deb|rpm|appimage|bat|cmd|ps1|scr|vbs)";
const dangerousFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.${DANGEROUS_FILE_EXTENSION})(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const zipFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.zip)(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const githubUserAttachmentPattern =
/^https:\/\/github\.com\/user-attachments\/files\/\d+\//i;
function normalizeAssociation(authorAssociation) {
return String(authorAssociation || "").trim().toUpperCase();
}
function isTrustedAuthor(authorAssociation) {
return TRUSTED_AUTHOR_ASSOCIATIONS.has(normalizeAssociation(authorAssociation));
}
function extractMatches(body, pattern) {
const files = [];
for (const match of body.matchAll(pattern)) {
files.push(match[1]);
}
return [...new Set(files)];
}
function extractDangerousFiles(body) {
return extractMatches(body, dangerousFilePattern);
}
function extractZipFiles(body) {
return extractMatches(body, zipFilePattern);
}
function isGitHubUserAttachment(file) {
return githubUserAttachmentPattern.test(file);
}
function detectSpamComment({ body, authorAssociation, userType } = {}) {
const normalizedBody = String(body || "").replace(/\s+/g, " ").trim();
const dangerousFiles = extractDangerousFiles(normalizedBody);
const zipFiles = extractZipFiles(normalizedBody);
const trustedAuthor = isTrustedAuthor(authorAssociation);
const botAuthor = String(userType || "").toLowerCase() === "bot";
const reasons = [];
let score = 0;
if (zipFiles.length > 0) {
score += 10;
reasons.push(`zip attachment or link: ${zipFiles.join(", ")}`);
} else if (dangerousFiles.length > 0) {
score += 10;
reasons.push(`dangerous downloadable file: ${dangerousFiles.join(", ")}`);
}
// Hard rule: any zip (or other dangerous downloadable) from an untrusted
// human is deleted. Maintainers and bots are exempt.
const spam =
!trustedAuthor &&
!botAuthor &&
(zipFiles.length > 0 || dangerousFiles.length > 0);
return {
spam,
score: spam ? score : 0,
reasons: spam ? reasons : [],
dangerousFiles: zipFiles.length > 0 ? zipFiles : dangerousFiles,
trustedAuthor,
botAuthor,
};
}
module.exports = {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
isTrustedAuthor,
};

View File

@@ -0,0 +1,114 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
} = require("./spam-comment-filter.cjs");
test("flags the fake Netcatty patch spam pattern", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[netcatty_patch.zip](https://example.com/netcatty_patch.zip)\n\nMan, that terminal rendering bug is such a pain. It looks like the sftp module is tripping over the encoding when it tries to sync the current path. I found a quick patch that fixes the character mapping in the backend so those black boxes finally disappear.",
});
assert.equal(result.spam, true);
});
test("flags fake patch spam when the filename ends a sentence", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "Download this patch.zip. It fixes the backend encoding so the terminal rendering black boxes disappear.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["patch.zip"]);
});
test("flags any zip from an outside user, including hash-named soft bait", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[63bf1862b52422e38b8cb170.zip](https://github.com/user-attachments/files/29784176/63bf1862b52422e38b8cb170.zip)\nI'd start with the docs inside",
});
assert.equal(result.spam, true);
assert.ok(
result.dangerousFiles.some((file) => file.includes("63bf1862b52422e38b8cb170.zip"))
);
});
test("flags ordinary zip log attachments from outside users", () => {
const result = detectSpamComment({
authorAssociation: "FIRST_TIME_CONTRIBUTOR",
userType: "User",
body: "[debug-logs.zip](https://github.com/user-attachments/files/29784176/debug-logs.zip)\nI attached logs from a failed connection. The app hangs after I click connect, and the logs show the SSH handshake timing out.",
});
assert.equal(result.spam, true);
});
test("flags other dangerous archives from outside users", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "See hotfix.dmg for the workaround.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["hotfix.dmg"]);
});
test("does not flag trusted maintainers even when sharing zip archives", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
body: "Try this temporary netcatty_patch.zip while I prepare the signed release. It fixes the rendering issue.",
});
assert.equal(result.spam, false);
});
test("does not flag bot comments that mention zip files", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
body: "Uploaded build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
});
test("does not flag comments without downloadable archives", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "I attached screenshots of the hang after connect. The SSH handshake times out.",
});
assert.equal(result.spam, false);
});
test("extracts risky file names from markdown links and plain text", () => {
assert.deepEqual(
extractDangerousFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."),
["fix.zip", "https://example.com/fix.zip", "hotfix.dmg"]
);
assert.deepEqual(extractZipFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."), [
"fix.zip",
"https://example.com/fix.zip",
]);
});
test("identifies GitHub user attachment URLs", () => {
assert.equal(
isGitHubUserAttachment("https://github.com/user-attachments/files/29784176/netcatty_fix.zip"),
true
);
assert.equal(isGitHubUserAttachment("https://example.com/netcatty_fix.zip"), false);
});

View File

@@ -0,0 +1,193 @@
/**
* Manual one-off sync — NOT wired into build or pack.
* node scripts/sync-docker-icons.mjs
*
* Generates colored SVGs (Simple Icons, CC0) plus a few official brand assets
* into public/docker-icons/, then writes domain/systemManager/dockerIconBundled.ts.
*/
import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as simpleIcons from 'simple-icons';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SRC_FILE = join(ROOT, 'domain/systemManager/dockerImageIcons.ts');
const OUT_DIR = join(ROOT, 'public/docker-icons');
const MANIFEST_FILE = join(ROOT, 'domain/systemManager/dockerIconBundled.ts');
/**
* Official assets not covered by Simple Icons — see public/docker-icons/NOTICE.md
* Prefer SVG when the upstream project publishes one; PNG only when no SVG exists.
*/
const OFFICIAL_ICONS = [
{
iconId: 'nacos',
file: 'nacos.svg',
url: 'https://raw.githubusercontent.com/nacos-group/nacos-logo/master/Nacos%20logo%20%E7%99%BD%E8%93%9D%E8%B5%84%E6%BA%90%205.svg',
},
{
iconId: 'polaris',
file: 'polaris.svg',
url: 'https://raw.githubusercontent.com/polarismesh/polaris/main/logo.svg',
},
{
iconId: 'memcached',
file: 'memcached.png',
url: 'https://memcached.org/images/memcached_link_125.png',
},
];
const slugById = parseStringRecord(SRC_FILE, 'SIMPLE_ICONS_SLUG');
const styleById = parseTileStyles(SRC_FILE);
const iconsBySlug = new Map();
for (const icon of Object.values(simpleIcons)) {
if (icon && typeof icon === 'object' && 'slug' in icon && 'path' in icon) {
iconsBySlug.set(icon.slug, icon);
}
}
mkdirSync(OUT_DIR, { recursive: true });
const bundled = [];
const iconFiles = {};
const skipped = [];
for (const [iconId, slug] of Object.entries(slugById)) {
const brand = iconsBySlug.get(slug);
if (!brand) {
skipped.push({ iconId, slug, reason: 'slug not in simple-icons' });
continue;
}
const iconColor = styleById[iconId]?.iconColor ?? 'ffffff';
const fill = iconColor.startsWith('#') ? iconColor : `#${iconColor}`;
const file = `${iconId}.svg`;
const svg = [
`<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">`,
` <title>${escapeXml(brand.title)}</title>`,
` <path d="${brand.path}" fill="${fill}"/>`,
`</svg>`,
'',
].join('\n');
writeFileSync(join(OUT_DIR, file), svg, 'utf8');
bundled.push(iconId);
iconFiles[iconId] = file;
}
for (const entry of OFFICIAL_ICONS) {
const response = await fetch(entry.url);
if (!response.ok) {
throw new Error(`Failed to download ${entry.iconId} from ${entry.url}: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(join(OUT_DIR, entry.file), buffer);
bundled.push(entry.iconId);
iconFiles[entry.iconId] = entry.file;
console.log(`Downloaded official ${entry.iconId} -> ${entry.file}`);
}
const bundledSet = new Set(bundled);
for (const file of readdirSync(OUT_DIR)) {
if (file === 'NOTICE.md') continue;
const iconId = file.replace(/\.(svg|png|webp)$/i, '');
const owned =
bundledSet.has(iconId) && iconFiles[iconId] === file;
if (!owned) {
unlinkSync(join(OUT_DIR, file));
console.log(`Removed stale ${file}`);
}
}
bundled.sort();
const fileEntries = bundled
.filter((id) => iconFiles[id] !== `${id}.svg`)
.map((id) => ` '${id}': '${iconFiles[id]}',`);
writeFileSync(
MANIFEST_FILE,
[
'// Auto-generated by scripts/sync-docker-icons.mjs — do not edit.',
'export const BUNDLED_DOCKER_ICON_IDS = new Set<string>([',
...bundled.map((id) => ` '${id}',`),
']);',
'',
'/** Non-default filenames (e.g. official PNG assets). Default is `${id}.svg`. */',
'export const DOCKER_ICON_FILES: Record<string, string> = {',
...fileEntries,
'};',
'',
].join('\n'),
'utf8',
);
console.log(`Wrote ${bundled.length} icons to public/docker-icons/`);
console.log(`Updated ${MANIFEST_FILE}`);
if (skipped.length > 0) {
console.log(`Skipped ${skipped.length} (no official Simple Icons slug):`);
for (const item of skipped) {
console.log(` ${item.iconId} -> ${item.slug}`);
}
}
function parseStringRecord(filePath, constName) {
const src = readFileSync(filePath, 'utf8');
const start = src.indexOf(`const ${constName}`);
if (start < 0) throw new Error(`Could not find ${constName} in ${filePath}`);
const braceStart = src.indexOf('{', start);
const braceEnd = findMatchingBrace(src, braceStart);
const block = src.slice(braceStart + 1, braceEnd);
const out = {};
for (const line of block.split('\n')) {
const match = line.match(/^\s*([a-zA-Z0-9_]+):\s*'([^']*)',?\s*$/);
if (match) out[match[1]] = match[2];
}
return out;
}
function parseTileStyles(filePath) {
const src = readFileSync(filePath, 'utf8');
const start = src.indexOf('const ICON_TILE_STYLE');
if (start < 0) throw new Error(`Could not find ICON_TILE_STYLE in ${filePath}`);
const braceStart = src.indexOf('{', start);
const braceEnd = findMatchingBrace(src, braceStart);
const block = src.slice(braceStart + 1, braceEnd);
const out = {};
const re =
/^\s*([a-zA-Z0-9_]+):\s*\{\s*background:\s*'([^']*)',\s*iconColor:\s*'([^']*)'\s*\},?\s*$/;
for (const line of block.split('\n')) {
const match = line.match(re);
if (match) {
out[match[1]] = { background: match[2], iconColor: match[3] };
}
}
return out;
}
function findMatchingBrace(src, openIndex) {
let depth = 0;
for (let i = openIndex; i < src.length; i += 1) {
if (src[i] === '{') depth += 1;
else if (src[i] === '}') {
depth -= 1;
if (depth === 0) return i;
}
}
throw new Error('Unbalanced braces while parsing dockerImageIcons.ts');
}
function escapeXml(value) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,142 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("background terminals stop painting without stopping output", {
skip: "run with Electron to exercise real terminal visibility and rendering",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("background-rendering")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const cleanup = (code) => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
const bundle = esbuild.buildSync({
stdin: {
contents: [
'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime";',
'export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";',
'export {resolveInactiveTerminalPaneStyle} from "./components/terminalPaneVisibility";',
].join("\n"),
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", target: "chrome148", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1100, height: 740,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='margin:0;background:%23111'><div id='root'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {createXTermRuntime,DEFAULT_TERMINAL_SETTINGS,resolveInactiveTerminalPaneStyle} = loaded.exports;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const ref = current => ({current});
const panes = [];
const root = document.getElementById('root');
root.style.cssText = 'position:relative;width:1050px;height:660px;overflow:hidden';
const visibleStyle = {left:'0px',top:'0px',width:'1050px',height:'660px'};
const apply = (pane, visible, layout = visibleStyle) => {
const style = visible ? layout : resolveInactiveTerminalPaneStyle(layout,{width:1050,height:660},false,true);
pane.el.style.cssText = 'position:absolute;background:#111';
Object.assign(pane.el.style,style);
pane.el.style.zIndex = visible ? '10' : '0';
};
for(let index=0;index<5;index++) {
const el=document.createElement('div');root.appendChild(el);
const pane={el,frames:0};apply(pane,true);
const r=createXTermRuntime({
container:el,host:{id:'test-'+index,label:'test',hostname:'localhost',protocol:'ssh'},
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,cursorBlink:false}),
terminalBackend:{write:()=>{},resize:()=>{},openExternalAvailable:false},
sessionRef:ref('test-'+index),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(false),
onBroadcastInputRef:ref(undefined),sessionId:'test-'+index,statusRef:ref('connected'),
commandBufferRef:ref(''),requestSearchFocus:()=>{},
});
r.fitAddon.fit();r.term.onRender(()=>pane.frames++);
pane.r=r;panes.push(pane);
await new Promise(resolve=>r.term.write('ready\\r\\n',resolve));
}
assert.ok(root.querySelector('canvas'),'exercise WebGL, not only a fake renderer');
const write=(pane,data)=>new Promise(resolve=>pane.r.term.write(data,resolve));
const size=pane=>[pane.r.term.cols,pane.r.term.rows];
const beforeSizes=panes.map(size);
for(let i=1;i<5;i++)apply(panes[i],false);
await wait(200);
const before=panes.map(p=>p.frames);
for(let tick=0;tick<12;tick++) {
await Promise.all(panes.map(p=>write(p,'background-'+tick+'\\r\\n')));
await wait(30);
}
const hiddenFrames=panes.map((p,i)=>p.frames-before[i]);
assert.ok(hiddenFrames[0]>0,'visible terminal still paints');
assert.deepEqual(hiddenFrames.slice(1),[0,0,0,0],'background terminals must not paint');
assert.deepEqual(panes.map(size),beforeSizes,'hiding preserves terminal dimensions');
for(const pane of panes) {
const b=pane.r.term.buffer.active;
assert.equal(b.getLine(b.baseY+b.cursorY-1).translateToString(true),'background-11','hidden output is already parsed');
}
// Cached right-hand split offsets can exceed a restored window's width.
apply(panes[4],false,{...visibleStyle,left:'1600px'});
await wait(150);
const offsetFrames=panes[4].frames;
await write(panes[4],'cached-offset\\r\\n');await wait(100);
assert.equal(panes[4].frames,offsetFrames,'cached split offsets must not leave hidden panes painting');
assert.ok(panes[4].el.getBoundingClientRect().right<=0,'park the entire cached split pane');
assert.deepEqual(size(panes[4]),beforeSizes[4]);
// No zero-width fitting during a window resize while tabs are parked.
root.style.width='850px';
assert.equal(panes[1].el.clientWidth,1050);
for(let tick=0;tick<12;tick++) {
const pane=panes[1+tick%4];
await write(pane,'switch-'+tick+'\\r\\n');
const start=pane.frames;apply(pane,true);await wait(100);
assert.ok(pane.frames>start,'reveal must repaint');
assert.deepEqual(size(pane),beforeSizes[1]);apply(pane,false);await wait(30);
}
// A full-screen program must keep interpreting cursor operations while hidden.
const tui=panes[2];
await write(tui,'\\x1b[?1049h\\x1b[2J\\x1b[HOLD');
await wait(100);const tuiBefore=tui.frames;
await write(tui,'\\x1b[HNEW\\x1b[2;1Hsecond row');await wait(100);
assert.equal(tui.frames,tuiBefore);
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
apply(tui,true,{...visibleStyle,width:'850px'});tui.r.fitAddon.fit();await wait(100);
assert.ok(tui.frames>tuiBefore);
assert.equal(tui.r.term.buffer.active.getLine(0).translateToString(true),'NEW');
await write(tui,'\\x1b[?1049l');
// Two visible split panes paint; parked panes continue parsing without painting.
for(const pane of panes)apply(pane,false);
apply(panes[0],true,{left:'0px',top:'0px',width:'425px',height:'660px'});
apply(panes[1],true,{left:'425px',top:'0px',width:'425px',height:'660px'});
panes[0].r.fitAddon.fit();panes[1].r.fitAddon.fit();await wait(150);
const splitBefore=panes.map(p=>p.frames);
await Promise.all(panes.map(p=>write(p,'split-final\\r\\n')));await wait(100);
assert.ok(panes[0].frames>splitBefore[0] && panes[1].frames>splitBefore[1]);
assert.deepEqual(panes.slice(2).map((p,i)=>p.frames-splitBefore[i+2]),[0,0,0]);
const summary={hiddenFrames,rapidSwitches:12,alternateScreen:true,split:true,sizes:beforeSizes};
panes.forEach(p=>p.r.dispose());root.replaceChildren();
assert.equal(document.querySelectorAll('.xterm').length,0);
return summary;
})()`);
console.log("TERMINAL_BACKGROUND_RENDERING_OK", JSON.stringify(result));
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}

View File

@@ -0,0 +1,111 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
require("node:test")("fit preserves terminal reading position", {
skip: "run with Electron for real xterm viewport coverage",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("fit-scroll")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const source = fs.readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const start = source.indexOf(" const safeFit = (options?: SafeFitOptions) => {");
const end = source.indexOf(" const prevIsResizingRef", start);
if (start < 0 || end < 0) throw new Error("Cannot extract production safeFit");
const safeFitSource = esbuild.transformSync(source.slice(start, end), { loader: "ts" }).code;
const bundle = esbuild.buildSync({
stdin: {
contents: 'export {Terminal} from "@xterm/xterm"; export {alignTerminalViewportScroll, forceSyncRenderAfterResize, createSynchronizedOutputFitScheduler} from "./components/terminal/terminalHelpers";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1000, height: 700,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false, backgroundThrottling: false },
});
await win.loadURL("data:text/html,<body style='background:%23111;color:white'><h3>Issue 3299: resize reading position</h3><div id='terminal'></div>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {Terminal,forceSyncRenderAfterResize,createSynchronizedOutputFitScheduler} = loaded.exports;
const alignTerminalViewportScroll = process.env.NETCATTY_FIT_BASELINE ? ()=>{} : loaded.exports.alignTerminalViewportScroll;
const wait = ms => new Promise(resolve=>setTimeout(resolve,ms));
const term = new Terminal({cols:80,rows:25,scrollback:1000,fontSize:14,allowProposedApi:true,smoothScrollDuration:0});
const el = document.getElementById('terminal');
el.style.cssText='width:900px;height:560px'; term.open(el);
const write = data => new Promise(resolve=>term.write(data,resolve));
const ref = current => ({current});
const termRef=ref(term), containerRef=ref(el), isRendererActiveRef=ref(true), lastFittedSizeRef=ref(null);
const autocompleteRepositionRef=ref(null), xtermRuntimeRef=ref(null), pendingWriteSafeFitRef=ref(null);
const synchronizedFitSchedulerRef=ref(createSynchronizedOutputFitScheduler());
let dimensions={cols:80,rows:25};
const fitAddonRef=ref({proposeDimensions:()=>dimensions});
const hasPendingTerminalWrites=()=>false;
const XTERM_PERFORMANCE_CONFIG={resize:{useRAF:false}};
const logger={warn:(message,error)=>{throw error || new Error(message)}};
${safeFitSource}
const safeFitRef=ref(safeFit);
const fit=(cols,rows)=>{dimensions={cols,rows};safeFit({force:true,immediate:true});};
const domRow=()=>term._core._viewport._scrollableElement.getScrollPosition().scrollTop / term._core._renderService.dimensions.css.cell.height;
const check=(expected,label)=>{
assert.equal(term.buffer.active.viewportY,expected,label+' buffer');
assert.ok(Math.abs(domRow()-expected)<0.01,label+' DOM expected '+expected+' actual '+domRow());
};
await write(Array.from({length:200},(_,i)=>'line '+i+' '+('A B repeated '.repeat(7))).join('\\r\\n'));
await wait(100); term.scrollToLine(70); await wait(50);
fit(80,16); await wait(80); check(70,'shrink');
fit(80,30); await wait(80); check(70,'grow');
for(let rows=29;rows>=12;rows--){fit(80,rows);await wait(20);check(70,'drag '+rows);}
term.scrollToBottom();await wait(50);fit(80,8);await wait(80);check(term.buffer.active.baseY,'pinned shrink');
term.scrollLines(-1);await wait(50);check(term.buffer.active.baseY-1,'first scroll after shrink');
term.scrollToLine(70);await wait(50);
const oldSize=[term.cols,term.rows];
await write('\\x1b[?2026h');fit(100,20);fit(120,24);await wait(80);
assert.deepEqual([term.cols,term.rows],oldSize,'no reflow inside synchronized frame');
term.scrollLines(-3);await wait(40);const selected=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(120);
assert.deepEqual([term.cols,term.rows],[120,24]);check(Math.min(selected,term.buffer.active.baseY),'user selected position');
await write('\\x1b[?2026h');fit(90,18);await write('\\x1b[?2026l\\x1b[?2026h');await wait(70);
assert.equal(term.cols,120,'a new synchronized frame delays retry');
await write('\\x1b[?2026l');await wait(100);check(Math.min(selected,term.buffer.active.baseY),'mode reentry');
await write('\\r\\n'+Array.from({length:1200},()=> 'A B repeated').join('\\r\\n'));
await wait(80);term.scrollToLine(100);await wait(30);
await write('\\x1b[?2026h');fit(130,22);await write('\\r\\n'.repeat(5));
const afterTrim=term.buffer.active.viewportY;
await write('\\x1b[?2026l');await wait(100);check(afterTrim,'scrollback trim');
await write('\\x1b[?2026h\\x1b[?1049h');fit(100,20);
await write('alternate output\\x1b[?1049l\\x1b[?2026l');await wait(100);
check(Math.min(afterTrim,term.buffer.active.baseY),'alternate buffer round trip');
await write('\\x1b[?2026h');fit(110,21);await wait(1400);
assert.equal(term.modes.synchronizedOutputMode,false,'xterm timeout releases output');
assert.equal(term.cols,110,'fit retried after output timeout');
const beforeDispose=term.cols;
await write('\\x1b[?2026h');fit(140,25);synchronizedFitSchedulerRef.current.dispose();
await write('\\x1b[?2026l');await wait(100);assert.equal(term.cols,beforeDispose,'teardown cancels retry');
assert.ok(el.querySelector('.xterm-screen').getBoundingClientRect().height > 0,'real rendered terminal');
return {passed:['shrink','grow','18 drag steps','bottom and next scroll','synchronized repeated spaced text','user scroll','mode reentry','full scrollback trim','alternate buffer','output timeout','cleanup'],viewportY:term.buffer.active.viewportY,domRow:domRow()};
})()`);
console.log(JSON.stringify(result));
if (process.env.NETCATTY_FIT_SCREENSHOT) {
fs.writeFileSync(process.env.NETCATTY_FIT_SCREENSHOT, (await win.webContents.capturePage()).toPNG());
}
cleanup(0);
}).catch(error=>{console.error(error);cleanup(1);});
}

View File

@@ -0,0 +1,329 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { Terminal } = require("@xterm/xterm");
const { Client } = require("ssh2");
const {
getFlowController,
writeSessionData,
} = require("../components/terminal/runtime/terminalSessionAttachment.ts");
const {
clearTerminalSessionFlowAck,
flushTerminalSessionFlowAck,
} = require("../components/terminal/runtime/terminalFlowAckBuffer.ts");
const {
flushPendingTerminalWritesBeforeHibernate,
hasPendingTerminalWrites,
} = require("../components/terminal/runtime/terminalUnfocusedRepaint.ts");
const COLS = 266;
const ROWS = 68;
const DEFAULT_STRESS_MS = 12_000;
const MIN_EXPECTED_BYTES = 8 * 1024 * 1024;
const parseTargets = () => {
if (!process.env.NETCATTY_TERMINAL_STRESS_TARGETS) return [];
const parsed = JSON.parse(process.env.NETCATTY_TERMINAL_STRESS_TARGETS);
if (!Array.isArray(parsed)) {
throw new TypeError("NETCATTY_TERMINAL_STRESS_TARGETS must be a JSON array");
}
return parsed;
};
const createContext = (sessionId, onAck, onPause) => {
const host = { showLineTimestamps: false };
return {
host,
hostRef: { current: host },
terminalSettingsRef: {
current: {
showLineTimestamps: false,
scrollOnOutput: false,
forcePromptNewLine: false,
},
},
terminalSettings: {
showLineTimestamps: false,
scrollOnOutput: false,
forcePromptNewLine: false,
},
terminalBackend: {
ackSessionFlow: onAck,
setSessionFlowPaused: onPause,
},
sessionRef: { current: sessionId },
isVisibleRef: { current: true },
isPaneVisibleRef: { current: true },
promptLineBreakStateRef: { current: undefined },
};
};
const readTerminalText = (term) => {
const buffer = term.buffer.active;
const lines = [];
for (let index = 0; index < buffer.length; index += 1) {
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
}
return lines.join("\n");
};
const pythonStressSource = String.raw`
import select
import sys
import time
cols = ${COLS}
rows = ${ROWS}
frame = 0
sys.stdout.write("\x1b[?1049h\x1b[2J")
sys.stdout.flush()
try:
while frame < 10000:
ready, _, _ = select.select([sys.stdin], [], [], 0)
if ready and "q" in sys.stdin.readline():
break
parts = ["\x1b[?2026h\x1b[H"]
for sweep in range(9):
color = (frame + sweep) % 216 + 16
fill = chr(65 + ((frame + sweep) % 26)) * cols
for row in range(1, rows + 1):
parts.append("\x1b[%d;1H\x1b[48;5;%dm%s" % (row, color, fill))
parts.append("\x1b[0m\x1b[?2026l")
sys.stdout.write("".join(parts))
sys.stdout.flush()
frame += 1
time.sleep(0.12)
finally:
sys.stdout.write("\x1b[?2026l\x1b[?1049l\r\nNETCATTY_STRESS_DONE frames=%d\r\n" % frame)
sys.stdout.flush()
`;
const buildRemoteCommand = () => {
const encoded = Buffer.from(pythonStressSource, "utf8").toString("base64");
return `python3 -c "import base64;exec(base64.b64decode('${encoded}'))"`;
};
const installSuppressedRendererWakeups = () => {
const requestDescriptor = Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame");
const cancelDescriptor = Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame");
const originalSetTimeout = globalThis.setTimeout;
const suppressedTimers = new Set();
let nextFrameId = 1;
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: () => {
const id = nextFrameId;
nextFrameId += 1;
return id;
},
});
Object.defineProperty(globalThis, "cancelAnimationFrame", {
configurable: true,
value: () => {},
});
globalThis.setTimeout = (callback, delay, ...args) => {
if (delay !== 24) {
return originalSetTimeout(callback, delay, ...args);
}
// Reproduce the second lost-wakeup edge from #2467: the visible idle
// safety timer never runs. The independent coalescer deadline must still
// carry output into xterm.
const timer = originalSetTimeout(() => {}, 2_147_483_647);
timer.unref?.();
suppressedTimers.add(timer);
return timer;
};
return () => {
globalThis.setTimeout = originalSetTimeout;
for (const timer of suppressedTimers) {
clearTimeout(timer);
}
if (requestDescriptor) {
Object.defineProperty(globalThis, "requestAnimationFrame", requestDescriptor);
} else {
Reflect.deleteProperty(globalThis, "requestAnimationFrame");
}
if (cancelDescriptor) {
Object.defineProperty(globalThis, "cancelAnimationFrame", cancelDescriptor);
} else {
Reflect.deleteProperty(globalThis, "cancelAnimationFrame");
}
};
};
const connect = (target) => new Promise((resolve, reject) => {
const connection = new Client();
connection.once("ready", () => resolve(connection));
connection.once("error", reject);
connection.connect({
host: target.host,
port: target.port ?? 22,
username: target.username,
password: target.password,
readyTimeout: 10_000,
});
});
const execStress = (connection) => new Promise((resolve, reject) => {
connection.exec(
buildRemoteCommand(),
{ pty: { term: "xterm-256color", cols: COLS, rows: ROWS } },
(error, stream) => {
if (error) reject(error);
else resolve(stream);
},
);
});
const waitForClose = (stream, timeoutMs) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("remote TUI did not exit after input")), timeoutMs);
stream.once("close", (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
const waitFor = async (predicate, timeoutMs, message) => {
const deadline = performance.now() + timeoutMs;
while (!predicate()) {
if (performance.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
};
const runRemoteStress = async (target) => {
const sessionId = `live-stress-${target.host}`;
const term = new Terminal({
cols: COLS,
rows: ROWS,
scrollback: 2_000,
allowProposedApi: true,
});
let connection;
let stream;
let ingressBytes = 0;
let ackedBytes = 0;
let chunkCount = 0;
let paused = false;
let pauseCount = 0;
let maxPendingBytes = 0;
let stderr = "";
let resolveFirstChunk;
const firstChunk = new Promise((resolve) => {
resolveFirstChunk = resolve;
});
let probingLostWakeup = true;
const ctx = createContext(
sessionId,
(_id, bytes) => {
ackedBytes += bytes;
},
(_id, nextPaused) => {
paused = nextPaused;
if (nextPaused) {
pauseCount += 1;
stream?.pause();
} else {
stream?.resume();
}
},
);
clearTerminalSessionFlowAck(sessionId);
const restoreWakeups = installSuppressedRendererWakeups();
const startedAt = performance.now();
try {
connection = await connect(target);
stream = await execStress(connection);
stream.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
stream.on("data", (chunk) => {
const data = chunk.toString("utf8");
ingressBytes += chunk.length;
chunkCount += 1;
writeSessionData(ctx, term, data, chunk.length);
maxPendingBytes = Math.max(
maxPendingBytes,
getFlowController(ctx, term).pendingBytes(),
);
if (probingLostWakeup) {
probingLostWakeup = false;
// Freeze ingress after one under-cap chunk, then close the frame gate.
// No later push is allowed to rescue this batch.
stream.pause();
ctx.isPaneVisibleRef.current = false;
resolveFirstChunk();
}
});
await firstChunk;
await new Promise((resolve) => setTimeout(resolve, 100));
ctx.isPaneVisibleRef.current = true;
await waitFor(
() => ackedBytes > 0,
1_000,
"first terminal batch was stranded after the visibility gate changed",
);
stream.resume();
await new Promise((resolve) => setTimeout(resolve, target.stressMs ?? DEFAULT_STRESS_MS));
const inputSentAt = performance.now();
stream.write("q\n");
const closed = await waitForClose(stream, 15_000);
const inputLatencyMs = performance.now() - inputSentAt;
const drained = await flushPendingTerminalWritesBeforeHibernate(term);
flushTerminalSessionFlowAck(sessionId);
const controllerPendingBytes = getFlowController(ctx, term).pendingBytes();
const terminalText = readTerminalText(term);
const result = {
host: target.host,
durationMs: Math.round(performance.now() - startedAt),
inputLatencyMs: Math.round(inputLatencyMs),
ingressBytes,
ackedBytes,
chunkCount,
pauseCount,
maxPendingBytes,
controllerPendingBytes,
pipelinePending: hasPendingTerminalWrites(term),
exitCode: closed.code,
signal: closed.signal,
markerSeen: terminalText.includes("NETCATTY_STRESS_DONE"),
};
assert.equal(stderr, "", `remote stderr: ${stderr}`);
assert.equal(closed.code, 0);
assert.equal(drained, true);
assert.equal(result.pipelinePending, false);
assert.equal(controllerPendingBytes, 0);
assert.equal(paused, false);
assert.equal(ackedBytes, ingressBytes);
assert.equal(result.markerSeen, true);
assert.ok(ingressBytes >= (target.minBytes ?? MIN_EXPECTED_BYTES));
assert.ok(inputLatencyMs < 5_000, `input took ${Math.round(inputLatencyMs)} ms`);
return result;
} finally {
restoreWakeups();
clearTerminalSessionFlowAck(sessionId);
stream?.destroy();
connection?.end();
term.dispose();
}
};
const targets = parseTargets();
test("live SSH TUI output does not strand the Netcatty terminal pipeline", {
skip: targets.length === 0 ? "set NETCATTY_TERMINAL_STRESS_TARGETS" : false,
timeout: Math.max(60_000, targets.length * 35_000),
}, async () => {
const results = [];
for (const target of targets) {
results.push(await runRemoteStress(target));
}
console.log(`NETCATTY_STRESS_RESULTS=${JSON.stringify(results)}`);
});

View File

@@ -0,0 +1,158 @@
"use strict";
if (!process.versions.electron) {
require("node:test")("tray panel fills its window through the app-lock gate", {
skip: "run npm run build && npm run test:tray-panel-layout",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { app, BrowserWindow, ipcMain, screen } = require("electron");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const { TRAY_PANEL_WIDTH, TRAY_PANEL_HEIGHT, placeTrayPanel } = require("../electron/bridges/trayPanelBounds.cjs");
const { windowsCssRoundedOverlayChromeOptions } = require("../electron/bridges/windowManager/windowsWindowChrome.cjs");
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("tray-layout-test")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
const scale = process.env.NETCATTY_TRAY_LAYOUT_SCALE || "1";
app.commandLine.appendSwitch("force-device-scale-factor", scale);
let win;
let panelBounds;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const finish = (code) => {
clearTimeout(watchdog);
if (win && !win.isDestroyed()) win.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove tray layout test data:", error.message);
}
app.exit(code);
};
const watchdog = setTimeout(() => {
console.error("TRAY_LAYOUT_FAIL timed out");
finish(1);
}, 30_000);
// Only the desktop service boundary is faked. Load the complete built tray
// route, including AppLockGate, providers, and the production styles.
const preload = path.join(userData, "preload.cjs");
fs.writeFileSync(preload, `
const { contextBridge, ipcRenderer } = require('electron');
let state = { initialized: true, locked: false, reason: null, version: 1 };
const listen = (channel, callback) => {
const listener = (_event, value) => callback(value);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
};
listen('test:lock-state', next => { state = next; });
contextBridge.exposeInMainWorld('netcatty', {
getAppLockRuntimeState: async () => state,
onAppLockRuntimeStateChanged: callback => listen('test:lock-state', callback),
onTrayPanelMenuData: callback => listen('test:menu-data', callback),
hideTrayPanel: () => ipcRenderer.send('test:hide-tray'),
});
`);
const waitFor = async (expression) => {
for (let attempt = 0; attempt < 100; attempt++) {
if (await win.webContents.executeJavaScript(expression)) return;
await delay(50);
}
assert.fail(`Timed out waiting for ${expression}`);
};
const checkLayout = async (label, locked = false, expectedBounds = panelBounds) => {
const actual = await win.webContents.executeJavaScript(`(() => {
const panel = document.getElementById('tray-panel-root');
const bounds = panel.getBoundingClientRect();
const background = document.querySelector('[data-app-lock-background]');
return {
width: bounds.width, height: bounds.height,
viewportWidth: innerWidth, viewportHeight: innerHeight, deviceScale: devicePixelRatio,
inert: background.inert, hidden: background.getAttribute('aria-hidden'),
};
})()`);
const detail = `${label}: ${JSON.stringify({ ...actual, expectedBounds, nativeBounds: win.getContentBounds() })}`;
assert.equal(actual.viewportWidth, expectedBounds.width, detail);
assert.equal(actual.viewportHeight, expectedBounds.height, detail);
if (process.platform === "win32") assert.equal(actual.deviceScale, Number(scale), label);
assert.equal(actual.width, actual.viewportWidth, detail);
assert.equal(actual.height, actual.viewportHeight, `${label}: panel must fill the window, got ${JSON.stringify(actual)}`);
assert.equal(actual.inert, locked, `${label}: background interaction guard`);
assert.equal(actual.hidden, locked ? "true" : null, `${label}: accessibility guard`);
console.log(`TRAY_LAYOUT_PASS ${label} scale=${actual.deviceScale} ${actual.width}x${actual.height}`);
};
void app.whenReady().then(async () => {
const { workArea, bounds, scaleFactor } = screen.getPrimaryDisplay();
console.log("TRAY_LAYOUT_DISPLAY", JSON.stringify({ workArea, bounds, scaleFactor }));
panelBounds = placeTrayPanel({
anchor: { x: workArea.x + workArea.width - 24, y: workArea.y + workArea.height, width: 24, height: 24 },
workArea,
width: TRAY_PANEL_WIDTH,
height: TRAY_PANEL_HEIGHT,
});
win = new BrowserWindow({
width: TRAY_PANEL_WIDTH,
height: TRAY_PANEL_HEIGHT,
show: false,
frame: false,
resizable: false,
...windowsCssRoundedOverlayChromeOptions(),
webPreferences: { preload, contextIsolation: true, sandbox: false, zoomFactor: 1 },
});
ipcMain.on("test:hide-tray", () => win.hide());
// Match showTrayPanel, including its intentional size limit when the
// display work area cannot fit the full 360x520 panel (e.g. at 200% scale).
win.setBounds(panelBounds, false);
await win.loadFile(path.join(__dirname, "../dist/index.html"), { hash: "/tray" });
await waitFor("Boolean(document.getElementById('tray-panel-root'))");
win.show();
await checkLayout("empty-first-open");
for (let attempt = 1; attempt <= 3; attempt++) {
win.hide();
win.setBounds(panelBounds, false);
win.show();
await checkLayout(`reopen-${attempt}`);
}
const sessions = Array.from({ length: 30 }, (_, i) => ({
id: `test-${i}`, label: `Test server ${i}`, hostLabel: `Test server ${i}`, status: "connected",
}));
win.webContents.send("test:menu-data", { sessions });
await waitFor("document.getElementById('tray-panel-root').textContent.includes('Test server 29')");
await checkLayout("many-sessions");
win.webContents.send("test:lock-state", { initialized: true, locked: true, reason: "manual", version: 2 });
await waitFor("document.querySelector('[data-app-lock-background]').inert");
await checkLayout("locked", true);
win.webContents.send("test:lock-state", { initialized: true, locked: false, reason: null, version: 3 });
await waitFor("!document.querySelector('[data-app-lock-background]').inert");
await checkLayout("unlocked");
win.webContents.send("test:menu-data", { sessions: [] });
await waitFor("!document.getElementById('tray-panel-root').textContent.includes('Test server 29')");
await checkLayout("empty-again");
// Also exercise a short work area on machines with a large desktop. The
// original regression leaves an empty panel at 250px, even in a 340px window.
const shortBounds = { ...panelBounds, height: Math.min(panelBounds.height, 340) };
win.hide();
win.setBounds(shortBounds, false);
win.show();
await waitFor(`innerHeight === ${shortBounds.height}`);
await checkLayout("short-work-area", false, shortBounds);
win.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
win.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
for (let attempt = 0; attempt < 50 && win.isVisible(); attempt++) await delay(20);
assert.equal(win.isVisible(), false, "Escape closes the panel");
finish(0);
}).catch((error) => {
console.error("TRAY_LAYOUT_FAIL", error);
finish(1);
});
}

View File

@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const { execFileSync } = require('node:child_process');
test('update-nix-release writes version and AppImage hashes', () => {
const root = path.join(__dirname, '..');
const script = path.join(root, '.github', 'scripts', 'update-nix-release.js');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-nix-release-'));
const artifacts = path.join(tmp, 'artifacts');
fs.mkdirSync(path.join(tmp, 'nix'));
fs.mkdirSync(artifacts);
const x64 = Buffer.from('x64 appimage');
const arm64 = Buffer.from('arm64 appimage');
fs.writeFileSync(path.join(artifacts, 'Netcatty-1.2.3-linux-x86_64.AppImage'), x64);
fs.writeFileSync(path.join(artifacts, 'Netcatty-1.2.3-linux-arm64.AppImage'), arm64);
execFileSync(process.execPath, [script, '--artifacts', artifacts, '--version', 'v1.2.3'], {
cwd: tmp,
stdio: 'pipe',
});
const releaseNix = fs.readFileSync(path.join(tmp, 'nix', 'release.nix'), 'utf8');
const x64Hash = `sha256-${crypto.createHash('sha256').update(x64).digest('base64')}`;
const arm64Hash = `sha256-${crypto.createHash('sha256').update(arm64).digest('base64')}`;
assert.match(releaseNix, /version = "1\.2\.3";/);
assert.match(releaseNix, new RegExp(`hash = "${x64Hash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}";`));
assert.match(releaseNix, new RegExp(`hash = "${arm64Hash.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}";`));
});

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
"use strict";
/**
* Local verification: packaged Cursor CLI MCP injection depends on a writable cwd.
*
* Proves:
* 1) mergeWorkspaceMcpJson fails when cwd is "/" (typical Dock/Finder launch)
* 2) mergeWorkspaceMcpJson succeeds in a writable directory (dev / terminal launch)
* 3) Packaged Netcatty process cwd differs: launch from "/" vs writable dir
* 4) Packaged MCP server script path exists under app.asar.unpacked
*
* Usage:
* node scripts/verify-cursor-cli-mcp-cwd.cjs
* node scripts/verify-cursor-cli-mcp-cwd.cjs --skip-app-launch
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawn, spawnSync } = require("node:child_process");
const {
mergeWorkspaceMcpJson,
resetMcpMergeRefcountsForTests,
resolveCursorCliWorkspaceCwd,
} = require("../electron/bridges/aiBridge/sdk/cursorCliDriver.cjs");
const SKIP_APP = process.argv.includes("--skip-app-launch");
const APP_BIN = "/Applications/Netcatty.app/Contents/MacOS/Netcatty";
const APP_MCP = "/Applications/Netcatty.app/Contents/Resources/app.asar.unpacked/electron/mcp/netcatty-mcp-server.cjs";
const TEST_DIR = path.join(os.homedir(), "netcatty-cli-cwd-test");
const FAKE_MCP = [{
name: "netcatty-remote-hosts",
type: "stdio",
command: APP_BIN,
args: [APP_MCP],
env: [
{ name: "ELECTRON_RUN_AS_NODE", value: "1" },
{ name: "NETCATTY_MCP_PORT", value: "1" },
{ name: "NETCATTY_MCP_TOKEN", value: "verify-token" },
{ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: "verify-chat" },
],
}];
const results = [];
function pass(name, detail) {
results.push({ ok: true, name, detail });
console.log(`PASS ${name}${detail ? `${detail}` : ""}`);
}
function fail(name, detail) {
results.push({ ok: false, name, detail });
console.error(`FAIL ${name}${detail ? `${detail}` : ""}`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function readProcessCwd(pid) {
const out = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
encoding: "utf8",
});
if (out.status !== 0) return null;
const line = String(out.stdout || "").split("\n").find((l) => l.startsWith("n"));
return line ? line.slice(1) : null;
}
async function launchAndReadCwd(launchCwd, label) {
if (!fs.existsSync(APP_BIN)) {
fail(`app cwd (${label})`, `missing ${APP_BIN}`);
return null;
}
const child = spawn(APP_BIN, [], {
cwd: launchCwd,
stdio: "ignore",
detached: true,
env: process.env,
});
const pid = child.pid;
child.unref();
let cwd = null;
for (let i = 0; i < 20; i++) {
await sleep(250);
cwd = readProcessCwd(pid);
if (cwd) break;
try {
process.kill(pid, 0);
} catch {
break;
}
}
try {
process.kill(pid, "SIGTERM");
} catch { /* already gone */ }
await sleep(300);
try {
process.kill(pid, "SIGKILL");
} catch { /* ignore */ }
return cwd;
}
function testMergeRootFails() {
resetMcpMergeRefcountsForTests();
try {
mergeWorkspaceMcpJson("/", FAKE_MCP);
fail("merge at cwd=/", "expected throw, but merge succeeded");
try {
fs.unlinkSync("/.cursor/mcp.json");
} catch { /* ignore */ }
} catch (err) {
pass("merge at cwd=/", `${err.code || "ERR"}: ${err.message}`);
}
}
function testMergeWritableSucceeds() {
resetMcpMergeRefcountsForTests();
fs.mkdirSync(TEST_DIR, { recursive: true });
const cursorDir = path.join(TEST_DIR, ".cursor");
const mcpPath = path.join(cursorDir, "mcp.json");
try {
fs.rmSync(cursorDir, { recursive: true, force: true });
} catch { /* ignore */ }
let handle;
try {
handle = mergeWorkspaceMcpJson(TEST_DIR, FAKE_MCP);
} catch (err) {
fail("merge in writable cwd", err.message);
return;
}
if (!fs.existsSync(mcpPath)) {
fail("merge in writable cwd", `missing ${mcpPath}`);
handle?.restore?.();
return;
}
let doc;
try {
doc = JSON.parse(fs.readFileSync(mcpPath, "utf8"));
} catch (err) {
fail("merge in writable cwd", `invalid json: ${err.message}`);
handle?.restore?.();
return;
}
const entry = doc?.mcpServers?.["netcatty-remote-hosts"];
if (!entry?.command) {
fail("merge in writable cwd", "netcatty-remote-hosts missing from mcp.json");
} else {
pass(
"merge in writable cwd",
`wrote ${mcpPath}; command=${entry.command}`,
);
}
handle?.restore?.();
// After restore of a previously non-existent file, mcp.json should be removed.
if (fs.existsSync(mcpPath)) {
fail("restore cleans mcp.json", `${mcpPath} still present after restore`);
} else {
pass("restore cleans mcp.json", "removed after turn-end restore");
}
}
function testMergeFailureIsLoud() {
const { runCursorCliTurn } = require("../electron/bridges/aiBridge/sdk/cursorCliDriver.cjs");
const calls = [];
const emitter = {
emitError: (message) => calls.push(message),
emitDone: () => {},
text: () => {},
reasoning: () => {},
reasoningEnd: () => {},
toolCall: () => {},
toolResult: () => {},
sessionId: () => {},
};
return runCursorCliTurn({
prompt: "hi",
binPath: "/bin/agent",
cwd: "/",
chatSessionId: "loud-fail",
getTempDir: () => path.join(os.tmpdir(), "netcatty-cli-loud-ok"),
model: "auto",
env: {},
permissionMode: "confirm",
injectedMcpServers: [{ name: "netcatty-remote-hosts", command: "node", args: ["x"] }],
emitter,
spawnImpl: () => {
throw new Error("spawn should not run after MCP merge failure");
},
mergeMcp: () => {
throw new Error("forced merge failure");
},
}).then(() => {
if (calls.length === 1 && /Failed to prepare Netcatty MCP for Cursor CLI/i.test(calls[0])) {
pass("merge failure is user-visible", calls[0]);
} else {
fail("merge failure is user-visible", JSON.stringify(calls));
}
});
}
function testPackagedMcpPath() {
if (fs.existsSync(APP_MCP)) {
pass("packaged MCP script exists", APP_MCP);
} else {
fail("packaged MCP script exists", `missing ${APP_MCP}`);
}
// Smoke: Electron binary can at least start the script as node long enough to print env requirement.
if (!fs.existsSync(APP_BIN)) {
fail("packaged MCP spawn smoke", `missing ${APP_BIN}`);
return;
}
const smoke = spawnSync(
APP_BIN,
[APP_MCP],
{
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
// Intentionally omit NETCATTY_MCP_PORT so server exits quickly with a known message.
},
encoding: "utf8",
timeout: 5000,
},
);
const errText = `${smoke.stderr || ""}${smoke.stdout || ""}`;
if (/NETCATTY_MCP_PORT not set/i.test(errText)) {
pass("packaged MCP spawn smoke", "Electron+ELECTRON_RUN_AS_NODE runs mcp server bootstrap");
} else {
fail(
"packaged MCP spawn smoke",
`unexpected exit=${smoke.status} signal=${smoke.signal} out=${errText.slice(0, 300)}`,
);
}
}
async function testAppLaunchCwds() {
if (SKIP_APP) {
console.log("SKIP packaged app cwd probes (--skip-app-launch)");
return;
}
// Ensure no leftover instance steals single-instance lock from a previous Dock launch.
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
await sleep(800);
const fromRoot = await launchAndReadCwd("/", "from /");
if (fromRoot === "/" || fromRoot === "/System/Volumes/Data") {
pass("packaged cwd when launched from /", fromRoot);
} else if (fromRoot == null) {
fail("packaged cwd when launched from /", "could not read process cwd (app exited early?)");
} else {
// Still useful: report actual cwd
fail("packaged cwd when launched from /", `expected / , got ${fromRoot}`);
}
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
await sleep(800);
fs.mkdirSync(TEST_DIR, { recursive: true });
const fromWritable = await launchAndReadCwd(TEST_DIR, "from writable");
if (fromWritable === TEST_DIR || fromWritable === path.resolve(TEST_DIR)) {
pass("packaged cwd when launched from writable dir", fromWritable);
} else if (fromWritable == null) {
fail("packaged cwd when launched from writable dir", "could not read process cwd");
} else {
fail(
"packaged cwd when launched from writable dir",
`expected ${TEST_DIR}, got ${fromWritable}`,
);
}
spawnSync("pkill", ["-x", "Netcatty"], { encoding: "utf8" });
}
function testResolveUsesTempOverRoot() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-fix-"));
try {
const resolved = resolveCursorCliWorkspaceCwd({
preferredCwd: "/",
chatSessionId: "verify-chat",
getTempDir: () => tempRoot,
});
const expected = path.join(tempRoot, "cursor-cli-mcp", "verify-chat");
if (resolved !== expected) {
fail("resolveCursorCliWorkspaceCwd", `expected ${expected}, got ${resolved}`);
return;
}
// Merge into the resolved workspace must succeed even when preferred cwd is /.
resetMcpMergeRefcountsForTests();
const handle = mergeWorkspaceMcpJson(resolved, FAKE_MCP);
const mcpPath = path.join(resolved, ".cursor", "mcp.json");
if (!fs.existsSync(mcpPath)) {
fail("resolveCursorCliWorkspaceCwd", `missing ${mcpPath}`);
handle?.restore?.();
return;
}
handle?.restore?.();
pass("resolveCursorCliWorkspaceCwd", `uses ${resolved} instead of /`);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
async function main() {
console.log("=== Cursor CLI MCP cwd verification ===\n");
testMergeRootFails();
testMergeWritableSucceeds();
await testMergeFailureIsLoud();
testResolveUsesTempOverRoot();
testPackagedMcpPath();
await testAppLaunchCwds();
const failed = results.filter((r) => !r.ok);
console.log(`\n=== Summary: ${results.length - failed.length}/${results.length} passed ===`);
if (failed.length) {
console.error("Failed checks:");
for (const f of failed) console.error(` - ${f.name}: ${f.detail}`);
process.exitCode = 1;
return;
}
console.log("\nConclusion:");
console.log("- Finder/Dock cwd=/ cannot host .cursor/mcp.json.");
console.log("- Fix: Cursor CLI turns use Netcatty temp cursor-cli-mcp/<chatId> workspace.");
console.log("- Merge failures now error out instead of silently dropping MCP.");
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
TEMP_DIR=""
usage() {
echo "Usage: $0 <amd64|arm64> [deb-file]" >&2
exit 1
}
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
require_cmd() {
local cmd="$1"
command -v "${cmd}" >/dev/null 2>&1 || {
echo "[deb-verify] missing required command: ${cmd}" >&2
exit 1
}
}
assert_exists() {
local file="$1"
if [[ ! -e "${file}" ]]; then
echo "[deb-verify] expected file does not exist: ${file}" >&2
exit 1
fi
}
assert_executable() {
local file="$1"
if [[ ! -x "${file}" ]]; then
echo "[deb-verify] expected executable file is missing or not executable: ${file}" >&2
exit 1
fi
}
log_file_info() {
local file="$1"
echo "[deb-verify] file: ${file}"
ls -lh "${file}"
file "${file}"
checksum "${file}"
}
assert_file_arch() {
local file="$1"
local expected="$2"
local info
info="$(file "${file}")"
echo "[deb-verify] arch-check: ${info}"
if [[ "${info}" != *"${expected}"* ]]; then
echo "[deb-verify] unexpected architecture for ${file}" >&2
echo "[deb-verify] expected substring: ${expected}" >&2
exit 1
fi
}
assert_loadable_native_module() {
local electron_bin="$1"
local native_module="$2"
if [[ "${VERIFY_LOAD:-1}" != "1" ]]; then
echo "[deb-verify] skipping native module load check for ${native_module} (VERIFY_LOAD=${VERIFY_LOAD:-1})"
return
fi
echo "[deb-verify] loading native module with packaged Electron runtime: ${native_module}"
ELECTRON_RUN_AS_NODE=1 "${electron_bin}" -e '
const path = require("node:path");
require(path.resolve(process.argv[1]));
console.log("[deb-verify] native module loaded successfully");
' "${native_module}"
}
resolve_file_from_glob() {
local search_dir="$1"
local pattern="$2"
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" -print | sort | head -n 1
}
resolve_single_file() {
local search_dir="$1"
local pattern="$2"
local file
file="$(resolve_file_from_glob "${search_dir}" "${pattern}")"
if [[ -z "${file}" ]]; then
echo "[deb-verify] no file matched ${pattern} under ${search_dir}" >&2
exit 1
fi
echo "${file}"
}
resolve_serialport_prebuild() {
local root="$1"
local arch="$2"
local prebuild_dir="${root}/prebuilds/linux-${arch}"
local file
file="$(find "${prebuild_dir}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
if [[ -z "${file}" ]]; then
echo "[deb-verify] serialport glibc prebuild not found under ${prebuild_dir}" >&2
exit 1
fi
echo "${file}"
}
verify_native_module() {
local label="$1"
local electron_bin="$2"
local file="$3"
local expected_machine="$4"
assert_exists "${file}"
echo "[deb-verify] verifying ${label}"
log_file_info "${file}"
assert_file_arch "${file}" "${expected_machine}"
assert_loadable_native_module "${electron_bin}" "${file}"
}
main() {
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage
fi
local deb_arch="$1"
local prebuild_arch
local expected_machine
local deb_file
local control_arch
local electron_bin
local main_binary
local build_release_pty
local prebuild_pty
local serialport_root
local build_release_serialport
local prebuild_serialport
require_cmd dpkg-deb
require_cmd file
case "${deb_arch}" in
amd64)
prebuild_arch="x64"
expected_machine="x86-64"
;;
arm64)
prebuild_arch="arm64"
expected_machine="ARM aarch64"
;;
*)
usage
;;
esac
if [[ $# -eq 2 ]]; then
deb_file="$2"
assert_exists "${deb_file}"
else
deb_file="$(resolve_single_file "release" "*-linux-${deb_arch}.deb")"
fi
echo "[deb-verify] verifying deb artifact: ${deb_file}"
log_file_info "${deb_file}"
control_arch="$(dpkg-deb -f "${deb_file}" Architecture)"
echo "[deb-verify] control architecture: ${control_arch}"
if [[ "${control_arch}" != "${deb_arch}" ]]; then
echo "[deb-verify] deb control architecture mismatch: expected ${deb_arch}, got ${control_arch}" >&2
exit 1
fi
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TEMP_DIR:-}"' EXIT
dpkg-deb -x "${deb_file}" "${TEMP_DIR}"
electron_bin="${TEMP_DIR}/opt/Netcatty/netcatty"
main_binary="${TEMP_DIR}/opt/Netcatty/netcatty"
build_release_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node"
prebuild_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${prebuild_arch}/pty.node"
serialport_root="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp"
build_release_serialport="${serialport_root}/build/Release/bindings.node"
prebuild_serialport="$(resolve_serialport_prebuild "${serialport_root}" "${prebuild_arch}")"
assert_executable "${electron_bin}"
echo "[deb-verify] verifying packaged binary architectures"
log_file_info "${main_binary}"
assert_file_arch "${main_binary}" "${expected_machine}"
verify_native_module "node-pty build/Release" "${electron_bin}" "${build_release_pty}" "${expected_machine}"
verify_native_module "node-pty prebuild" "${electron_bin}" "${prebuild_pty}" "${expected_machine}"
verify_native_module "serialport build/Release" "${electron_bin}" "${build_release_serialport}" "${expected_machine}"
verify_native_module "serialport glibc prebuild" "${electron_bin}" "${prebuild_serialport}" "${expected_machine}"
echo "[deb-verify] deb artifact verification passed for ${deb_file}"
}
main "$@"

View File

@@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 <x86_64|aarch64> [rpm-file]" >&2
exit 1
}
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
shasum -a 256 "$@"
fi
}
require_cmd() {
local cmd="$1"
command -v "${cmd}" >/dev/null 2>&1 || {
echo "[rpm-verify] missing required command: ${cmd}" >&2
exit 1
}
}
assert_exists() {
local file="$1"
if [[ ! -e "${file}" ]]; then
echo "[rpm-verify] expected file does not exist: ${file}" >&2
exit 1
fi
}
log_file_info() {
local file="$1"
echo "[rpm-verify] file: ${file}"
ls -lh "${file}"
file "${file}"
checksum "${file}"
}
resolve_file_from_glob() {
local search_dir="$1"
local pattern="$2"
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" -print | sort | head -n 1
}
resolve_single_file() {
local search_dir="$1"
local pattern="$2"
local file
file="$(resolve_file_from_glob "${search_dir}" "${pattern}")"
if [[ -z "${file}" ]]; then
echo "[rpm-verify] no file matched ${pattern} under ${search_dir}" >&2
exit 1
fi
echo "${file}"
}
assert_file_arch() {
local file="$1"
local expected="$2"
local actual
actual="$(rpm -qp --qf '%{ARCH}' "${file}")"
echo "[rpm-verify] rpm metadata architecture: ${actual}"
if [[ "${actual}" != "${expected}" ]]; then
echo "[rpm-verify] RPM metadata architecture mismatch for ${file}" >&2
echo "[rpm-verify] expected: ${expected}" >&2
echo "[rpm-verify] actual: ${actual}" >&2
exit 1
fi
}
assert_manifest_has_no_matches() {
local manifest="$1"
local pattern="$2"
local description="$3"
local matches
matches="$(printf "%s\n" "${manifest}" | grep -E "${pattern}" || true)"
if [[ -n "${matches}" ]]; then
echo "[rpm-verify] unexpected ${description} in RPM file list:" >&2
printf "%s\n" "${matches}" | head -n 20 >&2
exit 1
fi
}
main() {
if [[ $# -lt 1 || $# -gt 2 ]]; then
usage
fi
local rpm_arch="$1"
local rpm_file
local rpm_pattern
local manifest
require_cmd bsdtar
require_cmd file
require_cmd rpm
case "${rpm_arch}" in
x86_64|aarch64)
rpm_pattern="*-linux-${rpm_arch}.rpm"
;;
*)
usage
;;
esac
if [[ $# -eq 2 ]]; then
rpm_file="$2"
assert_exists "${rpm_file}"
else
rpm_file="$(resolve_single_file "release" "${rpm_pattern}")"
fi
echo "[rpm-verify] verifying rpm artifact: ${rpm_file}"
log_file_info "${rpm_file}"
assert_file_arch "${rpm_file}" "${rpm_arch}"
manifest="$(bsdtar -tf "${rpm_file}")"
if [[ -z "${manifest}" ]]; then
echo "[rpm-verify] RPM file list is empty or unreadable: ${rpm_file}" >&2
exit 1
fi
assert_manifest_has_no_matches \
"${manifest}" \
'^(\./)?usr/lib/\.build-id(/|$)' \
"/usr/lib/.build-id entries"
assert_manifest_has_no_matches \
"${manifest}" \
'(^|/)lib(ggml|ggml-base|transcribe)\.so([./0-9A-Za-z_-]*|$)' \
"libggml/libtranscribe entries"
echo "[rpm-verify] rpm artifact verification passed for ${rpm_file}"
}
main "$@"

View File

@@ -0,0 +1,275 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("inline keyword highlighting stays responsive", {
skip: "run with Electron so the real WebGL renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const showWindow = process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1";
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-keyword-highlight-perf")}-`);
electron.app.setPath("userData", userData);
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm performance test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
void electron.app.whenReady().then(async () => {
window = new electron.BrowserWindow({
show: showWindow,
width: 1000,
height: 640,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const serializePath = require.resolve("@xterm/addon-serialize", { paths: [appRoot] });
const highlighterPath = path.join(appRoot, "components/terminal/keywordHighlight.ts");
const pressurePath = path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts");
const highlighterBundle = esbuild.buildSync({
stdin: {
contents: [
`export * from ${JSON.stringify(highlighterPath)};`,
`export { noteTerminalOutputPressureData } from ${JSON.stringify(pressurePath)};`,
].join("\n"),
loader: "ts",
resolveDir: appRoot,
},
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
}).outputFiles[0].text;
const result = await window.webContents.executeJavaScript(`(async () => {
try {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const { SerializeAddon } = require(${JSON.stringify(serializePath)});
const highlighterModule = { exports: {} };
((module, exports) => { ${highlighterBundle} })(highlighterModule, highlighterModule.exports);
const { KeywordHighlighter, noteTerminalOutputPressureData } = highlighterModule.exports;
const term = new Terminal({
allowProposedApi: true,
cols: 120,
cursorBlink: false,
rows: 40,
scrollback: 10000,
});
term.open(document.getElementById("terminal"));
let renderer = "dom";
try {
term.loadAddon(new WebglAddon());
renderer = "webgl";
} catch {}
const serializer = new SerializeAddon();
term.loadAddon(serializer);
const highlighter = new KeywordHighlighter(term);
const redRules = [{
id: "error",
label: "Error",
patterns: ["ERROR", "failed", "10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"],
color: "#F87171",
enabled: true,
}];
const blueRules = redRules.map(rule => ({ ...rule, color: "#60A5FA" }));
highlighter.setRules(redRules, true);
const originalRecolorRange = highlighter.recolorRange.bind(highlighter);
let measuredRecolorRows = 0;
let measuredRefreshes = 0;
highlighter.recolorRange = (startY, endY, refresh, force) => {
measuredRecolorRows += Math.abs(endY - startY) + 1;
if (refresh) measuredRefreshes += 1;
return originalRecolorRange(startY, endY, refresh, force);
};
const write = data => new Promise(resolve => term.write(data, resolve));
const waitPaint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// #3271: inspect the first actual renderer frame for tail -200f and
// subsequent coalesced log ticks, before the deferred catch-up can run.
let streamingFrames = 0;
let uncoloredStreamingFrame = false;
const streamingRender = term.onRender(() => {
streamingFrames += 1;
for (let row = 0; row < term.rows; row += 1) {
const line = term.buffer.active.getLine(term.buffer.active.viewportY + row);
const column = line?.translateToString(true).indexOf("ERROR") ?? -1;
if (column >= 0 && line.getCell(column).getFgColor() !== 0xf87171) {
uncoloredStreamingFrame = true;
}
}
});
for (const lines of [200, 12, 12, 12]) {
const batch = Array.from({ length: lines }, () => "log ERROR").join("\\r\\n") + "\\r\\n";
noteTerminalOutputPressureData(term, batch);
await write(batch);
await waitPaint();
await new Promise(resolve => setTimeout(resolve, 120));
}
streamingRender.dispose();
if (uncoloredStreamingFrame) throw new Error("Streaming output rendered before keyword color");
if (streamingFrames < 4) throw new Error("Streaming renderer frames were not observed");
await highlighter.whenSettled();
term.reset();
await write("row-1 ERROR\\r\\nrow-2 ERROR\\r\\nrow-3 ERROR\\r\\nrow-4 ERROR");
await write("\\x1b[1;");
await write(
"1Hrow-2 ERROR\\x1b[2;1Hrow-3 ERROR"
+ "\\x1b[3;1Hrow-4 ERROR\\x1b[4;1Hprompt ER",
);
await write("ROR\\x1b[1;1H");
await waitPaint();
const moshFrame = serializer.serialize({ scrollback: 0 });
const moshHighlightCount = (moshFrame.match(/38;2;248;113;113m/g) || []).length;
term.reset();
const moshRows = 40;
const seedMoshRows = async () => {
await write(Array.from(
{ length: moshRows },
(_, index) => "old-" + (index + 1) + " ERROR",
).join("\\r\\n"));
};
const rowFrames = Array.from(
{ length: moshRows },
(_, index) => "\\x1b[" + (index + 1) + ";1Hnew-" + (index + 1)
+ " ERROR\\x1b[1;1H",
);
await seedMoshRows();
measuredRecolorRows = 0;
measuredRefreshes = 0;
const mergedMoshStart = performance.now();
await write(rowFrames.join(""));
await waitPaint();
const mergedMoshMs = performance.now() - mergedMoshStart;
const mergedMoshRecolorRows = measuredRecolorRows;
const mergedMoshRefreshes = measuredRefreshes;
term.reset();
await seedMoshRows();
measuredRecolorRows = 0;
measuredRefreshes = 0;
const splitMoshStart = performance.now();
for (const rowFrame of rowFrames) await write(rowFrame);
await waitPaint();
const splitMoshMs = performance.now() - splitMoshStart;
const splitMoshRecolorRows = measuredRecolorRows;
const splitMoshRefreshes = measuredRefreshes;
const splitMoshFrame = serializer.serialize({ scrollback: 0 });
const splitMoshHighlightCount = (splitMoshFrame.match(/38;2;248;113;113m/g) || []).length;
term.reset();
let history = "";
for (let line = 0; line < 10000; line += 1) {
history += "2026-08-13 worker=" + (line % 32) + " ERROR failed from 10.2." + (line % 255) + "." + ((line * 7) % 255) + "\\r\\n";
}
const initialStart = performance.now();
noteTerminalOutputPressureData(term, history);
await write(history);
await waitPaint();
const initialWriteMs = performance.now() - initialStart;
await new Promise(resolve => setTimeout(resolve, 650));
await highlighter.whenSettled();
const rebuildsBeforeEnter = highlighter.rebuildCount;
const enterStart = performance.now();
const prompt = "\\r\\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
await write(prompt);
await waitPaint();
const enterWriteMs = performance.now() - enterStart;
await new Promise(resolve => setTimeout(resolve, 650));
await highlighter.whenSettled();
const enterRebuildCount = highlighter.rebuildCount;
const rebuildStart = performance.now();
highlighter.setRules(blueRules, true);
await highlighter.whenSettled();
await waitPaint();
const rebuildMs = performance.now() - rebuildStart;
const serialized = serializer.serialize({ scrollback: 10000 });
const pristine = highlighter.serializeAddon.serialize({ scrollback: 10000 });
const state = {
renderer,
moshHighlightCount,
mergedMoshMs,
mergedMoshRecolorRows,
mergedMoshRefreshes,
splitMoshMs,
splitMoshRecolorRows,
splitMoshRefreshes,
splitMoshHighlightCount,
rawChars: history.length,
initialWriteMs,
rebuildsBeforeEnter,
enterWriteMs,
enterRebuildCount,
rebuildMs,
rebuildCount: highlighter.rebuildCount,
blueMatchCount: (serialized.match(/38;2;96;165;250m/g) || []).length,
pristineHasNetcattyColor: /38;2;(248;113;113|96;165;250)m/.test(pristine),
};
highlighter.dispose();
term.dispose();
return state;
} catch (error) {
return { executionError: String(error && (error.stack || error)) };
}
})()`);
assert.equal(result.executionError, undefined, JSON.stringify(result));
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
assert.equal(result.renderer, "webgl", JSON.stringify(result));
}
assert.ok(result.moshHighlightCount >= 4, JSON.stringify(result));
assert.ok(result.splitMoshHighlightCount >= 40, JSON.stringify(result));
assert.ok(result.splitMoshRecolorRows <= 80, JSON.stringify(result));
assert.ok(result.splitMoshRefreshes <= 80, JSON.stringify(result));
assert.ok(result.splitMoshMs < 500, `split Mosh repaint regressed: ${JSON.stringify(result)}`);
assert.equal(result.enterRebuildCount, result.rebuildsBeforeEnter, JSON.stringify(result));
assert.equal(result.rebuildCount, result.rebuildsBeforeEnter + 1, JSON.stringify(result));
assert.ok(result.blueMatchCount >= 10000, JSON.stringify(result));
assert.equal(result.pristineHasNetcattyColor, false, JSON.stringify(result));
assert.ok(result.enterWriteMs < 150, `Enter write regressed: ${JSON.stringify(result)}`);
assert.ok(result.rebuildMs < 1000, `10k-line rule rebuild regressed: ${JSON.stringify(result)}`);
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_PERFORMANCE_OK ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}

View File

@@ -0,0 +1,366 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron) {
const test = require("node:test");
test("keyword highlighting keeps sustained output responsive", {
skip: "run with Electron so the real WebGL renderer is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const electron = require("electron");
const esbuild = require("esbuild");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const mainRef = process.env.NETCATTY_TERMINAL_PERF_MAIN_REF ?? "origin/main";
const chunkCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_CHUNKS ?? "1600", 10);
const roundCount = Number.parseInt(process.env.NETCATTY_TERMINAL_PERF_ROUNDS ?? "3", 10);
const scrollback = Number.parseInt(
process.env.NETCATTY_TERMINAL_PERF_SCROLLBACK ?? "50000",
10,
);
const userData = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("xterm-highlight-throughput")}-`);
electron.app.setPath("userData", userData);
electron.app.commandLine.appendSwitch("js-flags", "--expose-gc");
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm throughput test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
const buildModule = (source, resolveDir, plugins = []) => esbuild.buildSync({
stdin: { contents: source, loader: "ts", resolveDir },
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins,
}).outputFiles[0].text;
const buildMainModule = async () => {
const entryPath = "components/terminal/__keywordHighlightThroughputEntry.ts";
const entrySource = [
'export * from "./keywordHighlight";',
'export { noteTerminalOutputPressureData } from "./runtime/terminalOutputPressure";',
].join("\n");
const resolveMainFile = (repoPath) => {
const candidates = repoPath === entryPath
? [entryPath]
: [repoPath, `${repoPath}.ts`, `${repoPath}.tsx`, `${repoPath}/index.ts`, `${repoPath}/index.tsx`];
for (const candidate of candidates) {
if (candidate === entryPath) return candidate;
const exists = childProcess.spawnSync(
"git",
["cat-file", "-e", `${mainRef}:${candidate}`],
{ cwd: appRoot, stdio: "ignore" },
).status === 0;
if (exists) return candidate;
}
throw new Error(`Unable to resolve ${mainRef} source: ${repoPath}`);
};
const readMainFile = (repoPath) => repoPath === entryPath
? entrySource
: childProcess.execFileSync("git", ["show", `${mainRef}:${repoPath}`], {
cwd: appRoot,
encoding: "utf8",
});
const mainPlugin = {
name: "main-source",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
if (args.kind !== "entry-point") return undefined;
return { path: entryPath, namespace: "main-source" };
});
build.onResolve({ filter: /^\.\.?\// }, (args) => ({
path: resolveMainFile(
path.posix.normalize(path.posix.join(path.posix.dirname(args.importer), args.path)),
),
namespace: "main-source",
}));
build.onLoad({ filter: /.*/, namespace: "main-source" }, (args) => ({
contents: readMainFile(args.path),
loader: args.path.endsWith(".json") ? "json" : args.path.endsWith(".tsx") ? "tsx" : "ts",
resolveDir: path.posix.dirname(args.path),
}));
},
};
return (await esbuild.build({
entryPoints: [entryPath],
bundle: true,
format: "cjs",
platform: "browser",
target: "chrome142",
write: false,
plugins: [mainPlugin],
})).outputFiles[0].text;
};
void electron.app.whenReady().then(async () => {
const oldBundle = await buildMainModule();
const currentBundle = buildModule([
`export * from ${JSON.stringify(path.join(appRoot, "components/terminal/keywordHighlight.ts"))};`,
`export { noteTerminalOutputPressureData } from ${JSON.stringify(path.join(appRoot, "components/terminal/runtime/terminalOutputPressure.ts"))};`,
].join("\n"), appRoot);
window = new electron.BrowserWindow({
show: process.env.NETCATTY_TERMINAL_PERF_SHOW_WINDOW === "1",
width: 1000,
height: 640,
paintWhenInitiallyHidden: true,
webPreferences: {
backgroundThrottling: false,
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:920px;height:560px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const loadBundle = source => {
const loaded = { exports: {} };
((module, exports) => { eval(source); })(loaded, loaded.exports);
return loaded.exports;
};
const oldModule = loadBundle(${JSON.stringify(oldBundle)});
const currentModule = loadBundle(${JSON.stringify(currentBundle)});
const rules = [
{ id: "info", label: "Info", patterns: ["INFO"], color: "#60A5FA", enabled: true },
{ id: "warn", label: "Warn", patterns: ["WARN"], color: "#FBBF24", enabled: true },
{ id: "error", label: "Error", patterns: ["ERROR", "failed"], color: "#F87171", enabled: true },
{ id: "ip", label: "IP", patterns: ["10\\\\.2\\\\.\\\\d+\\\\.\\\\d+"], color: "#4ADE80", enabled: true },
];
const makeChunk = index => {
let chunk = "";
for (let line = 0; line < 64; line += 1) {
chunk += "2026-08-13 INFO worker=" + (line % 32) + " WARN ERROR failed from 10.2."
+ ((index + line) % 255) + "." + ((index * 7 + line) % 255) + " payload="
+ "x".repeat(24) + "\\r\\n";
}
return chunk;
};
const chunks = Array.from({ length: ${chunkCount} }, (_, index) => makeChunk(index));
const totalChars = chunks.reduce((total, chunk) => total + chunk.length, 0);
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const run = async kind => {
document.getElementById("terminal").replaceChildren();
globalThis.gc?.();
await wait(20);
const v8 = require("node:v8");
const heapBefore = v8.getHeapStatistics().used_heap_size;
const term = new Terminal({
allowProposedApi: true,
cols: 120,
cursorBlink: false,
rows: 40,
scrollback: ${scrollback},
});
term.open(document.getElementById("terminal"));
let renderer = "dom";
try {
term.loadAddon(new WebglAddon());
renderer = "webgl";
} catch {}
const selectedModule = kind === "old" ? oldModule : currentModule;
const Highlighter = kind === "old"
? oldModule.KeywordHighlighter
: currentModule.KeywordHighlighter;
const highlighter = kind === "raw" ? null : new Highlighter(term);
highlighter?.setRules(rules, true);
const sustainedOnly = process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY === "1";
let maxPendingPristineBytes = 0;
const pendingSample = setInterval(() => {
maxPendingPristineBytes = Math.max(
maxPendingPristineBytes,
highlighter?.pendingPristineBytes ?? 0,
);
}, 5);
const write = data => new Promise(resolve => term.write(data, resolve));
const callbackLatencies = [];
const heartbeatLatencies = [];
let heartbeatAt = performance.now();
const heartbeat = setInterval(() => {
const now = performance.now();
heartbeatLatencies.push(now - heartbeatAt);
heartbeatAt = now;
}, 10);
let renders = 0;
const renderDisposable = term.onRender(() => { renders += 1; });
const streamStarted = performance.now();
for (let index = 0; index < chunks.length; index += 1) {
const chunk = chunks[index];
selectedModule.noteTerminalOutputPressureData(term, chunk);
const callbackStarted = performance.now();
await write(chunk);
callbackLatencies.push(performance.now() - callbackStarted);
if (index % 16 === 15) await wait(0);
}
const streamMs = performance.now() - streamStarted;
const rendersAtStreamEnd = renders;
const quietStarted = performance.now();
await wait(sustainedOnly ? 0 : 700);
const quietDelayMs = performance.now() - quietStarted;
const streamHeartbeatCount = heartbeatLatencies.length;
const streamMaxHeartbeatMs = Math.max(0, ...heartbeatLatencies);
const rendersBeforeSettle = renders;
const settleStarted = performance.now();
if (!sustainedOnly) await highlighter?.whenSettled?.();
const settleWaitMs = performance.now() - settleStarted;
const rendersDuringSettle = renders - rendersBeforeSettle;
const quietWorkMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
let paintTimedOut = false;
if (!sustainedOnly) {
await Promise.race([
new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))),
wait(1000).then(() => { paintTimedOut = true; }),
]);
}
const rendersAfterSettle = renders - rendersBeforeSettle - rendersDuringSettle;
const quietCatchUpMs = performance.now() - quietStarted - (sustainedOnly ? 0 : 700);
clearInterval(heartbeat);
clearInterval(pendingSample);
globalThis.gc?.();
await wait(20);
const heapAfter = v8.getHeapStatistics().used_heap_size;
callbackLatencies.sort((left, right) => left - right);
const percentile = value => callbackLatencies[Math.min(
callbackLatencies.length - 1,
Math.floor(callbackLatencies.length * value),
)];
const state = {
kind,
renderer,
streamMs,
mibPerSecond: totalChars / 1024 / 1024 / (streamMs / 1000),
callbackP50Ms: percentile(0.5),
callbackP95Ms: percentile(0.95),
callbackP99Ms: percentile(0.99),
maxHeartbeatMs: streamMaxHeartbeatMs,
maxCatchUpHeartbeatMs: Math.max(0, ...heartbeatLatencies.slice(streamHeartbeatCount)),
quietCatchUpMs,
quietWorkMs,
quietDelayMs,
settleWaitMs,
paintTimedOut,
rendersDuringStream: rendersAtStreamEnd,
rendersDuringSettle,
rendersAfterSettle,
heapDeltaMiB: (heapAfter - heapBefore) / 1024 / 1024,
rebuildCount: highlighter?.rebuildCount ?? 0,
rebuildTimings: highlighter?.lastRebuildTimings ?? {},
maxPendingPristineBytes,
};
renderDisposable.dispose();
highlighter?.dispose();
term.dispose();
return state;
};
const rounds = [];
for (let round = 0; round < ${roundCount}; round += 1) {
for (const kind of ["raw", "old", "new"]) rounds.push(await run(kind));
}
return { totalChars, chunks: chunks.length, rounds };
})()`, true);
if (process.env.NETCATTY_TERMINAL_PERF_REQUIRE_WEBGL === "1") {
for (const round of result.rounds) assert.equal(round.renderer, "webgl", JSON.stringify(round));
}
const median = values => values.sort((left, right) => left - right)[Math.floor(values.length / 2)];
const byKind = kind => result.rounds.filter(round => round.kind === kind);
const oldStreamMs = median(byKind("old").map(round => round.streamMs));
const newStreamMs = median(byKind("new").map(round => round.streamMs));
const oldP99Ms = median(byKind("old").map(round => round.callbackP99Ms));
const newP99Ms = median(byKind("new").map(round => round.callbackP99Ms));
const oldHeartbeatMs = median(byKind("old").map(round => round.maxHeartbeatMs));
const newHeartbeatMs = median(byKind("new").map(round => round.maxHeartbeatMs));
const newCatchUpHeartbeatMs = median(byKind("new").map(round => round.maxCatchUpHeartbeatMs));
const newQuietCatchUpMs = median(byKind("new").map(round => round.quietCatchUpMs));
// Catch-up is deliberately deferred until output becomes quiet. Keep a
// strict event-loop stall limit, while allowing total work to scale with
// the retained history size (5s at 10k lines, 10s at 50k lines).
const maxQuietCatchUpMs = Math.max(5000, scrollback / 5);
const rawStreamMs = median(byKind("raw").map(round => round.streamMs));
const rawP99Ms = median(byKind("raw").map(round => round.callbackP99Ms));
assert.ok(
newStreamMs <= oldStreamMs * 1.1,
`new sustained throughput regressed more than 10%: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= oldP99Ms * 1.15,
`new p99 write latency regressed more than 15%: ${JSON.stringify(result)}`,
);
assert.ok(
newHeartbeatMs <= Math.max(75, oldHeartbeatMs * 3),
`new event-loop stall regressed: ${JSON.stringify(result)}`,
);
assert.ok(
newCatchUpHeartbeatMs <= 350,
`quiet catch-up blocked the event loop for over 350 ms: ${JSON.stringify(result)}`,
);
// Product gate is vs main decorations (10% above). Raw xterm is a sanity
// bound only: cell-color wrap + DOM CI is typically ~15-18% over raw.
assert.ok(
newStreamMs <= rawStreamMs * 1.25,
`new sustained throughput regressed more than 25% versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newP99Ms <= Math.max(10, rawP99Ms * 1.25),
`new p99 write latency regressed versus raw xterm: ${JSON.stringify(result)}`,
);
assert.ok(
newQuietCatchUpMs <= maxQuietCatchUpMs,
`quiet-period catch-up exceeded ${maxQuietCatchUpMs} ms: ${JSON.stringify(result)}`,
);
if (process.env.NETCATTY_TERMINAL_PERF_SUSTAINED_ONLY !== "1") {
assert.equal(
byKind("new").every(round => round.rebuildCount === 1),
true,
`bulk output must catch up exactly once after becoming quiet: ${JSON.stringify(result)}`,
);
assert.equal(
byKind("new").every(round => !round.paintTimedOut && round.rendersDuringSettle <= 1),
true,
`quiet catch-up must repaint atomically: ${JSON.stringify(result)}`,
);
assert.equal(
byKind("new").every(round => (round.rendersAfterSettle ?? 0) <= 1),
true,
`quiet catch-up must not keep painting after settle: ${JSON.stringify(result)}`,
);
}
assert.equal(
byKind("new").every(round => round.maxPendingPristineBytes <= 12 * 1024 * 1024),
true,
`pristine backlog must stay bounded: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_KEYWORD_HIGHLIGHT_THROUGHPUT ${JSON.stringify(result)}\n`);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}

View File

@@ -0,0 +1,191 @@
"use strict";
if (!process.versions.electron) {
const test = require("node:test");
test("macOS Option drag keeps rectangular and remote mouse behavior", {
skip: "run with Electron on macOS",
}, () => {});
} else if (process.platform !== "darwin") {
const skipElectron = require("electron");
process.stdout.write("XTERM_MACOS_COLUMN_SELECTION_SKIP platform is not macOS\n");
skipElectron.app.exit(0);
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const electron = require("electron");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const appRoot = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(
`${tempDirBridge.getTempFilePath("xterm-macos-selection")}-`,
);
electron.app.setPath("userData", userData);
electron.app.on("window-all-closed", () => {});
let window = null;
const cleanup = (exitCode) => {
if (window && !window.isDestroyed()) window.destroy();
try {
fs.rmSync(userData, { recursive: true, force: true });
} catch (error) {
console.warn("Unable to remove xterm selection test data:", error);
} finally {
electron.app.exit(exitCode);
}
};
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
void electron.app.whenReady().then(async () => {
window = new electron.BrowserWindow({
show: process.env.NETCATTY_XTERM_SELECTION_SHOW_WINDOW === "1",
width: 860,
height: 400,
paintWhenInitiallyHidden: true,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
// The harness imports the shipped ESM bundle directly from disk.
webSecurity: false,
backgroundThrottling: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body{margin:0;background:#111}#terminal{width:800px;height:320px;padding:20px}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const xtermEsmUrl = pathToFileURL(path.join(path.dirname(xtermPath), "xterm.mjs")).href;
await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = await import(${JSON.stringify(xtermEsmUrl)});
const term = new Terminal({
cols: 20,
rows: 6,
fontFamily: "Menlo",
fontSize: 18,
allowProposedApi: true,
macOptionClickForcesSelection: true,
macOptionIsMeta: false,
altClickMovesCursor: true,
});
let data = "";
term.onData(value => { data += value; });
term.open(document.getElementById("terminal"));
const content = [
"ABCDEFGHIJKLMNOPQRST",
"abcdefghijklmnopqrst",
"01234567890123456789",
"!@#$%^&*()_+-=[]{}|;",
].join("\\r\\n");
window.harness = {
reset: (mouseTracking, optionAsMeta) => new Promise(resolve => {
data = "";
term.reset();
term.clearSelection();
term.options.macOptionIsMeta = optionAsMeta;
term.options.altClickMovesCursor = !optionAsMeta;
term.write((mouseTracking ? "\\x1b[?1002h\\x1b[?1006h" : "\\x1b[?1002l\\x1b[?1006l") + content, resolve);
}),
geometry: () => {
const screen = term.element.querySelector(".xterm-screen").getBoundingClientRect();
return {
left: screen.left,
top: screen.top,
cellWidth: screen.width / term.cols,
cellHeight: screen.height / term.rows,
};
},
state: () => ({
selection: term.getSelection(),
data,
mouseTrackingMode: term.modes.mouseTrackingMode,
optionAsMeta: term.options.macOptionIsMeta,
}),
};
})()`);
const drag = async ({ alt = false } = {}) => {
const geometry = await window.webContents.executeJavaScript("window.harness.geometry()");
const point = (column, row) => ({
x: Math.round(geometry.left + geometry.cellWidth * (column + 0.5)),
y: Math.round(geometry.top + geometry.cellHeight * (row + 0.5)),
});
const start = point(1, 0);
const end = point(5, 2);
/** @type {Array<"alt">} */
const modifiers = alt ? ["alt"] : [];
/** @type {Array<"alt" | "leftbuttondown">} */
const moveModifiers = alt ? ["alt", "leftbuttondown"] : ["leftbuttondown"];
window.webContents.sendInputEvent({ type: "mouseDown", ...start, button: "left", clickCount: 1, modifiers });
window.webContents.sendInputEvent({ type: "mouseMove", ...end, button: "left", modifiers: moveModifiers });
window.webContents.sendInputEvent({ type: "mouseUp", ...end, button: "left", clickCount: 1, modifiers });
await delay(100);
return window.webContents.executeJavaScript("window.harness.state()");
};
const runScenario = async (mouseTracking, alt, optionAsMeta = false) => {
await window.webContents.executeJavaScript(
`window.harness.reset(${mouseTracking}, ${optionAsMeta})`,
);
return drag({ alt });
};
const normal = await runScenario(false, false);
const option = await runScenario(false, true);
const remote = await runScenario(true, false);
const remoteOption = await runScenario(true, true);
const optionAsMeta = await runScenario(false, true, true);
const remoteOptionAsMeta = await runScenario(true, true, true);
const optionAfterMeta = await runScenario(false, true, false);
const expectedColumn = "BCDEF\nbcdef\n12345";
const expectedNormal = "BCDEFGHIJKLMNOPQRST\nabcdefghijklmnopqrst\n012345";
assert.equal(
normal.selection,
expectedNormal,
`ordinary drag must remain a normal selection: ${JSON.stringify(normal)}`,
);
assert.equal(option.selection, expectedColumn, `Option drag must select columns: ${JSON.stringify(option)}`);
assert.equal(remote.selection, "", `remote drag must not create a local selection: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<0;\d+;\d+M/, `remote drag must report mouse down: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<32;\d+;\d+M/, `remote drag must report mouse movement: ${JSON.stringify(remote)}`);
assert.match(remote.data, /\x1b\[<0;\d+;\d+m/, `remote drag must report mouse up: ${JSON.stringify(remote)}`);
assert.equal(
remoteOption.selection,
expectedColumn,
`Option drag must force rectangular selection in mouse mode: ${JSON.stringify(remoteOption)}`,
);
assert.equal(remoteOption.data, "", `forced local selection must not emit mouse reports: ${JSON.stringify(remoteOption)}`);
assert.equal(
optionAsMeta.selection,
expectedNormal,
`Option-as-Meta must disable rectangular selection: ${JSON.stringify(optionAsMeta)}`,
);
assert.equal(
remoteOptionAsMeta.selection,
expectedNormal,
`Option-as-Meta must disable rectangular selection in mouse mode: ${JSON.stringify(remoteOptionAsMeta)}`,
);
assert.equal(
remoteOptionAsMeta.data,
"",
`Option must still force local selection in mouse mode: ${JSON.stringify(remoteOptionAsMeta)}`,
);
assert.equal(
optionAfterMeta.selection,
expectedColumn,
`turning Option-as-Meta off must restore rectangular selection: ${JSON.stringify(optionAfterMeta)}`,
);
process.stdout.write(
`XTERM_MACOS_COLUMN_SELECTION_OK ${JSON.stringify({ normal, option, remote, remoteOption, optionAsMeta, remoteOptionAsMeta, optionAfterMeta })}\n`,
);
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}

View File

@@ -0,0 +1,266 @@
"use strict";
if (!process.versions.electron) {
const test = require("node:test");
test("xterm WebGL atlas stays within renderer texture capacity", {
skip: "run with Electron so WebGL is available",
}, () => {});
} else {
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const electron = require("electron");
const appRoot = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-xterm-webgl-overflow-"));
electron.app.setPath("userData", userData);
electron.app.commandLine.appendSwitch("use-angle", "swiftshader");
electron.app.commandLine.appendSwitch("enable-unsafe-swiftshader");
electron.app.on("window-all-closed", () => {});
const cleanup = (exitCode) => {
fs.rmSync(userData, { recursive: true, force: true });
electron.app.exit(exitCode);
};
void electron.app.whenReady().then(async () => {
const window = new electron.BrowserWindow({
show: false,
width: 900,
height: 560,
paintWhenInitiallyHidden: true,
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
sandbox: false,
},
});
await window.loadURL(
"data:text/html;charset=utf-8," + encodeURIComponent(
"<!doctype html><style>html,body,#terminal{width:800px;height:480px;margin:0}</style><div id=terminal></div>",
),
);
const xtermPath = require.resolve("@xterm/xterm", { paths: [appRoot] });
const webglPath = require.resolve("@xterm/addon-webgl", { paths: [appRoot] });
const result = await window.webContents.executeJavaScript(`(async () => {
const { Terminal } = require(${JSON.stringify(xtermPath)});
const { WebglAddon } = require(${JSON.stringify(webglPath)});
const container = document.getElementById("terminal");
const errors = [];
window.addEventListener("error", event => {
errors.push(String(event.error?.stack || event.message || event.error));
event.preventDefault();
});
window.addEventListener("unhandledrejection", event => {
errors.push(String(event.reason?.stack || event.reason));
event.preventDefault();
});
const bootstrap = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
bootstrap.open(container);
const bootstrapAddon = new WebglAddon({ preserveDrawingBuffer: true });
bootstrap.loadAddon(bootstrapAddon);
await new Promise(resolve => setTimeout(resolve, 50));
const bootstrapAtlas = bootstrap._core?._renderService?._renderer?.value?._charAtlas;
if (!bootstrapAtlas) throw new Error("WebGL texture atlas was not created");
bootstrapAtlas.constructor.maxAtlasPages = 4;
bootstrapAtlas.constructor.maxTextureSize = 512;
bootstrap.dispose();
container.replaceChildren();
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
term.open(container);
const addon = new WebglAddon({ preserveDrawingBuffer: true });
let removals = 0;
addon.onRemoveTextureAtlasCanvas(() => { removals += 1; });
term.loadAddon(addon);
await new Promise(resolve => setTimeout(resolve, 50));
const renderer = term._core?._renderService?._renderer?.value;
const atlas = renderer?._charAtlas;
const glyphRenderer = renderer?._glyphRenderer?.value;
if (!atlas || !glyphRenderer || renderer !== addon._renderer) {
throw new Error("WebGL renderer internals are unavailable");
}
const captureCellSignatures = columns => {
const gl = renderer._gl;
const canvas = renderer._canvas;
const cell = renderer.dimensions?.device?.cell;
if (!gl || !canvas || !cell?.width || !cell?.height) {
throw new Error("WebGL canvas dimensions are unavailable");
}
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const grid = 4;
return columns.map(column => {
const sums = new Array(grid * grid * 4).fill(0);
const counts = new Array(grid * grid).fill(0);
const startX = Math.max(0, Math.floor(column * cell.width));
const endX = Math.min(canvas.width, Math.ceil((column + 1) * cell.width));
const startY = 0;
const endY = Math.min(canvas.height, Math.ceil(cell.height));
for (let y = startY; y < endY; y += 1) {
const gridY = Math.min(grid - 1, Math.floor((y / cell.height) * grid));
const sourceY = canvas.height - y - 1;
for (let x = startX; x < endX; x += 1) {
const gridX = Math.min(
grid - 1,
Math.floor(((x - column * cell.width) / cell.width) * grid),
);
const bucket = gridY * grid + gridX;
const pixel = (sourceY * canvas.width + x) * 4;
for (let channel = 0; channel < 4; channel += 1) {
sums[bucket * 4 + channel] += pixels[pixel + channel];
}
counts[bucket] += 1;
}
}
return sums.map((sum, index) => {
const count = counts[Math.floor(index / 4)];
return count > 0 ? sum / count : 0;
});
});
};
const signatureDiff = (left, right) => {
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference = Math.max(difference, Math.abs(left[index] - right[index]));
}
return difference;
};
const maxSignatureDiff = (left, right) => Math.max(
...left.map((signature, index) => signatureDiff(signature, right[index])),
);
const writeAndWaitForRender = (data, label) => new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
disposable.dispose();
reject(new Error("timed out waiting for terminal render: " + label));
}, 10000);
const disposable = term.onRender(() => {
clearTimeout(timeout);
disposable.dispose();
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
term.write(data);
});
const marker = "AFTER_EVICTION_0123456789";
const markerColumns = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J\\x1b[97m" + marker + "\\x1b[0m",
"initial marker",
);
const markerReference = captureCellSignatures(markerColumns);
const generateUniqueGlyphFlood = (count, offset) => {
const base = 0x4E00;
const range = 0x9FFF - base;
const perRow = 40;
let output = "";
for (let index = 0; index < count; index += 1) {
output += String.fromCodePoint(base + ((offset + index) % range));
if ((index + 1) % perRow === 0 && index + 1 < count) output += "\\r\\n";
}
return output;
};
const glyphsPerChunk = 23 * 40 - 1;
let peakPages = atlas.pages.length;
for (let chunk = 0; chunk < 32; chunk += 1) {
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J" + generateUniqueGlyphFlood(glyphsPerChunk, chunk * glyphsPerChunk),
"normal atlas flood " + chunk,
);
peakPages = Math.max(peakPages, atlas.pages.length);
if (errors.length > 0 || atlas.pages.length > glyphRenderer._atlasTextures.length) break;
}
await writeAndWaitForRender(
"\\x1b[H\\x1b[2J\\x1b[97m" + marker + "\\x1b[0m",
"post-eviction marker",
);
const markerAfterEviction = captureCellSignatures(markerColumns);
const markerPixelDiff = maxSignatureDiff(markerReference, markerAfterEviction);
const normalPages = atlas.pages.length;
const normalRemovals = removals;
await writeAndWaitForRender("\\x1b[H\\x1b[2J", "wide-glyph blank reference");
const wideColumns = [0, 7, 15, 23, 31];
const blankSignatures = captureCellSignatures(wideColumns);
const cellWidth = renderer.dimensions.device.cell.width;
const joinedLength = Math.min(term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32);
if (joinedLength * cellWidth <= atlas._textureSize) {
throw new Error("joined glyph is not wider than a normal atlas page");
}
const wideMarker = "W".repeat(joinedLength);
term.registerCharacterJoiner(text => text.startsWith(wideMarker) ? [[0, joinedLength]] : []);
await writeAndWaitForRender("\\x1b[H" + wideMarker, "oversized glyph");
const wideSignatures = captureCellSignatures(wideColumns);
const minimumWidePixelDiff = Math.min(
...wideSignatures.map(
(signature, index) => signatureDiff(signature, blankSignatures[index]),
),
);
const state = {
errors,
pages: normalPages,
peakPages,
textures: glyphRenderer._atlasTextures.length,
removals,
normalRemovals,
markerPixelDiff,
oversizedPages: atlas.pages.length,
oversizedPageCreated: !!atlas._overflowSizePage,
oversizedRemovals: removals - normalRemovals,
minimumWidePixelDiff,
};
term.dispose();
return state;
})()`);
assert.equal(result.textures, 4, `expected a deterministic 4-texture test cap: ${JSON.stringify(result)}`);
assert.equal(result.errors.length, 0, `WebGL rendering threw after atlas growth: ${result.errors[0] || ""}`);
assert.ok(
result.peakPages <= result.textures,
`atlas grew beyond renderer texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.normalRemovals > 0,
`normal atlas pages never exercised capacity recovery: ${JSON.stringify(result)}`,
);
assert.ok(
result.markerPixelDiff <= 14,
`rendered text changed after atlas eviction: ${JSON.stringify(result)}`,
);
assert.equal(
result.pages,
result.textures,
`normal atlas pages did not reach texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedPageCreated,
`oversized glyph did not use its dedicated atlas page: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedPages <= result.textures,
`oversized glyph exceeded texture capacity: ${JSON.stringify(result)}`,
);
assert.ok(
result.oversizedRemovals > 0,
`oversized glyph did not evict full atlas pages: ${JSON.stringify(result)}`,
);
assert.ok(
result.minimumWidePixelDiff > 14,
`every sampled part of the oversized glyph must render visible pixels: ${JSON.stringify(result)}`,
);
process.stdout.write(`XTERM_WEBGL_ATLAS_OVERFLOW_OK ${JSON.stringify(result)}\n`);
window.destroy();
cleanup(0);
}).catch((error) => {
console.error(error);
cleanup(1);
});
}