[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
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:
367
scripts/afterPackMacUuid.cjs
Normal file
367
scripts/afterPackMacUuid.cjs
Normal 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;
|
||||
340
scripts/afterPackMacUuid.test.cjs
Normal file
340
scripts/afterPackMacUuid.test.cjs
Normal 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
4251
scripts/ai-automation.cjs
Normal file
File diff suppressed because it is too large
Load Diff
5527
scripts/ai-automation.test.cjs
Normal file
5527
scripts/ai-automation.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
276
scripts/ai-brave-search.cjs
Normal file
276
scripts/ai-brave-search.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
34
scripts/ai-brave-search.test.cjs
Normal file
34
scripts/ai-brave-search.test.cjs
Normal 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/);
|
||||
});
|
||||
90
scripts/beforePackCursorSdk.cjs
Normal file
90
scripts/beforePackCursorSdk.cjs
Normal 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;
|
||||
214
scripts/beforePackCursorSdk.test.cjs
Normal file
214
scripts/beforePackCursorSdk.test.cjs
Normal 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\//);
|
||||
});
|
||||
62
scripts/bench-sync-crdt.ts
Normal file
62
scripts/bench-sync-crdt.ts
Normal 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);
|
||||
173
scripts/build-et/build-linux.sh
Normal file
173
scripts/build-et/build-linux.sh
Normal 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"
|
||||
133
scripts/build-et/build-macos.sh
Normal file
133
scripts/build-et/build-macos.sh
Normal 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"
|
||||
129
scripts/build-et/build-windows.ps1
Normal file
129
scripts/build-et/build-windows.ps1
Normal 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
|
||||
}
|
||||
182
scripts/build-windows-hello-helper.cjs
Normal file
182
scripts/build-windows-hello-helper.cjs
Normal 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,
|
||||
};
|
||||
154
scripts/build-windows-hello-helper.test.cjs
Normal file
154
scripts/build-windows-hello-helper.test.cjs
Normal 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);
|
||||
});
|
||||
12
scripts/clean-vite-cache.cjs
Normal file
12
scripts/clean-vite-cache.cjs
Normal 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);
|
||||
551
scripts/compare-ci-test-baseline.cjs
Normal file
551
scripts/compare-ci-test-baseline.cjs
Normal 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,
|
||||
};
|
||||
483
scripts/compare-ci-test-baseline.test.cjs
Normal file
483
scripts/compare-ci-test-baseline.test.cjs
Normal 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
16
scripts/copy-monaco.cjs
Normal 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);
|
||||
374
scripts/electron-builder-config.test.cjs
Normal file
374
scripts/electron-builder-config.test.cjs
Normal 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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
38
scripts/electron-builder-keychain.test.cjs
Normal file
38
scripts/electron-builder-keychain.test.cjs
Normal 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',
|
||||
]);
|
||||
});
|
||||
41
scripts/electron-builder-retry.test.cjs
Normal file
41
scripts/electron-builder-retry.test.cjs
Normal 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];
|
||||
}
|
||||
});
|
||||
193
scripts/ensure-node-pty-linux.sh
Normal file
193
scripts/ensure-node-pty-linux.sh
Normal 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 "$@"
|
||||
76
scripts/et-extra-resources.cjs
Normal file
76
scripts/et-extra-resources.cjs
Normal 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 };
|
||||
107
scripts/et-extra-resources.test.cjs
Normal file
107
scripts/et-extra-resources.test.cjs
Normal 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"), []);
|
||||
});
|
||||
349
scripts/fetch-et-binaries.cjs
Normal file
349
scripts/fetch-et-binaries.cjs
Normal 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,
|
||||
};
|
||||
302
scripts/fetch-et-binaries.test.cjs
Normal file
302
scripts/fetch-et-binaries.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
365
scripts/fetch-mosh-binaries.cjs
Normal file
365
scripts/fetch-mosh-binaries.cjs
Normal 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,
|
||||
};
|
||||
328
scripts/fetch-mosh-binaries.test.cjs
Normal file
328
scripts/fetch-mosh-binaries.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
104
scripts/fixtures/research-permission-probe.cjs
Normal file
104
scripts/fixtures/research-permission-probe.cjs
Normal 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; });
|
||||
246
scripts/generate-app-icon-variants.py
Normal file
246
scripts/generate-app-icon-variants.py
Normal 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()
|
||||
27
scripts/generate-capability-tools.cjs
Normal file
27
scripts/generate-capability-tools.cjs
Normal 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`);
|
||||
33
scripts/generate-capability-tools.test.cjs
Normal file
33
scripts/generate-capability-tools.test.cjs
Normal 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);
|
||||
});
|
||||
60
scripts/generate-codex-app-server-schema.cjs
Normal file
60
scripts/generate-codex-app-server-schema.cjs
Normal 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 });
|
||||
31
scripts/generate-linux-icons.sh
Normal file
31
scripts/generate-linux-icons.sh
Normal 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
|
||||
57
scripts/generate-mac-icon.sh
Normal file
57
scripts/generate-mac-icon.sh
Normal 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"
|
||||
377
scripts/generate-plugin-contract.mjs
Normal file
377
scripts/generate-plugin-contract.mjs
Normal 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.");
|
||||
}
|
||||
45
scripts/generate-release-note.test.cjs
Normal file
45
scripts/generate-release-note.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
30
scripts/generate-tray-ico.py
Normal file
30
scripts/generate-tray-ico.py
Normal 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()
|
||||
154
scripts/github-workflow-build.test.cjs
Normal file
154
scripts/github-workflow-build.test.cjs
Normal 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`);
|
||||
}
|
||||
});
|
||||
468
scripts/github-workflow-ci.test.cjs
Normal file
468
scripts/github-workflow-ci.test.cjs
Normal 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/);
|
||||
});
|
||||
74
scripts/linux/after-install.tpl
Normal file
74
scripts/linux/after-install.tpl
Normal 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
|
||||
31
scripts/linux/after-remove.tpl
Normal file
31
scripts/linux/after-remove.tpl
Normal 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
|
||||
206
scripts/mac-dock-icon.test.cjs
Normal file
206
scripts/mac-dock-icon.test.cjs
Normal 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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
49
scripts/mosh-extra-resources.cjs
Normal file
49
scripts/mosh-extra-resources.cjs
Normal 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 };
|
||||
73
scripts/mosh-extra-resources.test.cjs
Normal file
73
scripts/mosh-extra-resources.test.cjs
Normal 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"), []);
|
||||
});
|
||||
92
scripts/nodePtyConptyPatch.cjs
Normal file
92
scripts/nodePtyConptyPatch.cjs
Normal 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,
|
||||
};
|
||||
147
scripts/patch-xterm-macos-column-selection.cjs
Normal file
147
scripts/patch-xterm-macos-column-selection.cjs
Normal 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,
|
||||
};
|
||||
86
scripts/patch-xterm-macos-column-selection.test.cjs
Normal file
86
scripts/patch-xterm-macos-column-selection.test.cjs
Normal 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 },
|
||||
}]);
|
||||
});
|
||||
107
scripts/patch-xterm-webgl-atlas.cjs
Normal file
107
scripts/patch-xterm-webgl-atlas.cjs
Normal 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;
|
||||
122
scripts/patch-xterm-webgl-atlas.test.cjs
Normal file
122
scripts/patch-xterm-webgl-atlas.test.cjs
Normal 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;
|
||||
});
|
||||
});
|
||||
132
scripts/prepare-ai-research-input.sh
Normal file
132
scripts/prepare-ai-research-input.sh
Normal 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"
|
||||
7
scripts/rebuildPatchedNodePty.cjs
Normal file
7
scripts/rebuildPatchedNodePty.cjs
Normal 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,
|
||||
});
|
||||
187
scripts/resolve-et-bin-release.cjs
Normal file
187
scripts/resolve-et-bin-release.cjs
Normal 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,
|
||||
};
|
||||
134
scripts/resolve-et-bin-release.test.cjs
Normal file
134
scripts/resolve-et-bin-release.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
246
scripts/resolve-mosh-bin-release.cjs
Normal file
246
scripts/resolve-mosh-bin-release.cjs
Normal 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,
|
||||
};
|
||||
184
scripts/resolve-mosh-bin-release.test.cjs
Normal file
184
scripts/resolve-mosh-bin-release.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
184
scripts/sftp-transfer-resume.live.test.cjs
Normal file
184
scripts/sftp-transfer-resume.live.test.cjs
Normal 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",
|
||||
}, () => {});
|
||||
}
|
||||
94
scripts/spam-comment-filter.cjs
Normal file
94
scripts/spam-comment-filter.cjs
Normal 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,
|
||||
};
|
||||
114
scripts/spam-comment-filter.test.cjs
Normal file
114
scripts/spam-comment-filter.test.cjs
Normal 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);
|
||||
});
|
||||
193
scripts/sync-docker-icons.mjs
Normal file
193
scripts/sync-docker-icons.mjs
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
142
scripts/terminal-background-rendering.live.test.cjs
Normal file
142
scripts/terminal-background-rendering.live.test.cjs
Normal 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);});
|
||||
}
|
||||
111
scripts/terminal-fit-scroll.live.test.cjs
Normal file
111
scripts/terminal-fit-scroll.live.test.cjs
Normal 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);});
|
||||
}
|
||||
329
scripts/terminal-output-stall.live.test.cjs
Normal file
329
scripts/terminal-output-stall.live.test.cjs
Normal 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)}`);
|
||||
});
|
||||
158
scripts/tray-panel-layout.live.test.cjs
Normal file
158
scripts/tray-panel-layout.live.test.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
34
scripts/update-nix-release.test.cjs
Normal file
34
scripts/update-nix-release.test.cjs
Normal 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, '\\$&')}";`));
|
||||
});
|
||||
343
scripts/verify-cursor-cli-mcp-cwd.cjs
Normal file
343
scripts/verify-cursor-cli-mcp-cwd.cjs
Normal 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;
|
||||
});
|
||||
208
scripts/verify-linux-deb-artifact.sh
Normal file
208
scripts/verify-linux-deb-artifact.sh
Normal 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 "$@"
|
||||
144
scripts/verify-linux-rpm-artifact.sh
Normal file
144
scripts/verify-linux-rpm-artifact.sh
Normal 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 "$@"
|
||||
275
scripts/xterm-keyword-highlight-performance.live.test.cjs
Normal file
275
scripts/xterm-keyword-highlight-performance.live.test.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
366
scripts/xterm-keyword-highlight-throughput.live.test.cjs
Normal file
366
scripts/xterm-keyword-highlight-throughput.live.test.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
191
scripts/xterm-macos-column-selection.live.test.cjs
Normal file
191
scripts/xterm-macos-column-selection.live.test.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
266
scripts/xterm-webgl-atlas-overflow.live.test.cjs
Normal file
266
scripts/xterm-webgl-atlas-overflow.live.test.cjs
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user