diff --git a/scripts/build-from-upstream.js b/scripts/build-from-upstream.js index 5e8dfa1a..fff44419 100644 --- a/scripts/build-from-upstream.js +++ b/scripts/build-from-upstream.js @@ -3,7 +3,9 @@ * build-from-upstream.js — Patch upstream Codex and repackage * * For macOS and Windows: no forge needed. - * Takes the upstream app, patches ASAR in-place, replaces codex CLI, outputs distributable. + * Takes the upstream app, patches ASAR in-place, and outputs a distributable. + * Windows keeps the upstream codex.exe so the app-server protocol stays aligned + * with the bundled cua_node/node_repl binaries from the same MSIX. * * Usage: * node scripts/build-from-upstream.js --platform mac-arm64 @@ -12,7 +14,7 @@ */ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); const PROJECT_ROOT = path.resolve(__dirname, ".."); const SRC_DIR = path.join(PROJECT_ROOT, "src"); @@ -49,6 +51,23 @@ function copyRecursive(src, dest) { return count; } +function clearExistingAsarUnpacked(asarPath) { + const unpackedPath = `${asarPath}.unpacked`; + if (fs.existsSync(unpackedPath)) { + fs.rmSync(unpackedPath, { recursive: true, force: true }); + } +} + +function asarCliPath() { + return path.join(PROJECT_ROOT, "node_modules", "@electron", "asar", "bin", "asar.mjs"); +} + +function packAsar(asarDir, asarPath, extraArgs = []) { + execFileSync(process.execPath, [asarCliPath(), "pack", asarDir, asarPath, ...extraArgs], { + stdio: "pipe", + }); +} + function resolveCodexVendor(platform) { const triple = TARGET_TRIPLE_MAP[platform]; if (!triple) return null; @@ -148,7 +167,11 @@ function buildMac(platform) { // 3. Repack patched ASAR const asarPath = path.join(resourcesDir, "app.asar"); console.log(" [asar pack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarDir}" "${asarPath}"`); + clearExistingAsarUnpacked(asarPath); + packAsar(asarDir, asarPath, [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty}", + "--unpack", "{**/*.node,**/node-pty/build/Release/*.exe}", + ]); // 4. Update ASAR integrity hash in Info.plist const infoPlist = path.join(outApp, "Contents", "Info.plist"); @@ -220,7 +243,10 @@ function buildWin(platform) { // Repack patched ASAR console.log(" [asar pack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarDir}" "${asarPath}"`); + clearExistingAsarUnpacked(asarPath); + packAsar(asarDir, asarPath, [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty,node_modules/@worklouder}", + ]); // Compute new hash and patch exe const newHash = computeAsarHeaderHash(asarPath); @@ -236,15 +262,18 @@ function buildWin(platform) { } } - // Replace codex CLI - replaceCodex(platform, resourcesDir, "codex.exe"); + // Keep the upstream Windows codex.exe. The Desktop app-server and + // cua_node/node_repl exchange Codex-specific MCP metadata; replacing only the + // CLI with @cometix/codex can make that protocol drift and break browser + // tools (for example: sandboxCwd must use the file URI scheme). + keepUpstreamCodex(platform, resourcesDir, "codex.exe"); // Create ZIP const version = getVersion(asarDir); const zipName = `Codex-win-x64-${version}.zip`; const zipPath = path.join(OUT_DIR, zipName); console.log(` [zip] ${zipName}`); - execSync(`7zz a -tzip -mx=5 "${zipPath}" .`, { cwd: outApp }); + createZip(zipPath, outApp); const sizeMB = (fs.statSync(zipPath).size / 1048576).toFixed(1); console.log(` [ok] ${zipPath} (${sizeMB} MB)`); @@ -301,6 +330,51 @@ function replaceCodex(platform, resourcesDir, binName) { } } +function keepUpstreamCodex(platform, resourcesDir, binName) { + const codexPath = path.join(resourcesDir, binName); + if (!fs.existsSync(codexPath)) { + console.log(` [!] upstream ${binName} not found for ${platform}`); + return; + } + console.log(` [codex] keeping upstream ${binName}`); +} + +function createZip(zipPath, cwd) { + let sevenZipError = null; + try { + execFileSync("7zz", ["a", "-tzip", "-mx=5", zipPath, "."], { cwd, stdio: "pipe" }); + return; + } catch (e) { + sevenZipError = e; + } + + if (process.platform === "win32") { + try { + execFileSync("powershell", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "& { param($src, $zip) " + + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + "if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }; " + + "[System.IO.Compression.ZipFile]::CreateFromDirectory($src, $zip, " + + "[System.IO.Compression.CompressionLevel]::Optimal, $false) }", + cwd, + zipPath, + ], { stdio: "pipe" }); + return; + } catch (e) { + throw new Error(`Failed to create ZIP with 7zz or PowerShell: 7zz: ${sevenZipError.message}; PowerShell: ${e.message}`); + } + } + + try { + execFileSync("zip", ["-r", zipPath, "."], { cwd, stdio: "pipe" }); + } catch (e) { + throw new Error(`Failed to create ZIP with 7zz or zip: 7zz: ${sevenZipError.message}; zip: ${e.message}`); + } +} + function getVersion(asarDir) { try { const pkg = JSON.parse(fs.readFileSync(path.join(asarDir, "package.json"), "utf-8")); diff --git a/scripts/prepare-src.js b/scripts/prepare-src.js index 4bd50cb1..2086fc53 100644 --- a/scripts/prepare-src.js +++ b/scripts/prepare-src.js @@ -1,12 +1,14 @@ #!/usr/bin/env node /** - * Pre-build: Repack patched ASAR, replace codex CLI, assemble for forge. + * Pre-build: Repack patched ASAR, align platform resources, assemble for forge. * * Flow: * 1. Repack _asar/ -> app.asar (with patches applied) - * 2. Replace codex binary with @cometix/codex version + * 2. Replace codex binary with @cometix/codex version where needed * 3. Copy everything to src/ for forge (app.asar + unpacked + resources) * + * For Windows: keep upstream codex.exe so app-server and cua_node/node_repl + * MCP metadata stay protocol-compatible. * For Linux: strip macOS-only resources, add Linux codex from @cometix/codex * * Usage: @@ -15,7 +17,7 @@ */ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); const SRC = path.join(__dirname, "..", "src"); const PROJECT_ROOT = path.join(__dirname, ".."); @@ -36,6 +38,16 @@ const MACOS_STRIP = new Set([ ]); const MACOS_STRIP_DIRS = new Set(["native"]); +function asarCliPath() { + return path.join(PROJECT_ROOT, "node_modules", "@electron", "asar", "bin", "asar.mjs"); +} + +function packAsar(asarDir, asarPath, extraArgs = []) { + execFileSync(process.execPath, [asarCliPath(), "pack", asarDir, asarPath, ...extraArgs], { + stdio: "pipe", + }); +} + function copyRecursive(src, dest, skipFiles, skipDirs) { fs.mkdirSync(dest, { recursive: true }); let count = 0; @@ -163,15 +175,29 @@ function main() { // 1. Repack _asar/ -> app.asar const repackedAsar = path.join(sourceDir, "app.asar"); console.log(" [repack] _asar/ -> app.asar"); - execSync(`npx asar pack "${asarContentDir}" "${repackedAsar}"`); + const asarPackArgs = isLinux + ? [] + : platform === "win" + ? ["--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty,node_modules/@worklouder}"] + : [ + "--unpack-dir", "{node_modules/better-sqlite3,node_modules/node-pty}", + "--unpack", "{**/*.node,**/node-pty/build/Release/*.exe}", + ]; + packAsar(asarContentDir, repackedAsar, asarPackArgs); const asarSize = (fs.statSync(repackedAsar).size / 1048576).toFixed(1); console.log(` [ok] app.asar: ${asarSize} MB`); - // 2. Replace codex binary with @cometix/codex + // 2. Replace codex binary with @cometix/codex where needed. + // Windows must keep the upstream codex.exe from the MSIX. The Desktop + // app-server and bundled cua_node/node_repl exchange Codex-specific MCP + // metadata; mixing a different @cometix/codex build can break browser tools + // with errors such as "sandboxCwd must use the file URI scheme". const isWin = platform === "win"; const codexBinName = isWin ? "codex.exe" : "codex"; - const vendorCodex = resolveCodexVendor(platform); - if (vendorCodex) { + const vendorCodex = isWin ? null : resolveCodexVendor(platform); + if (isWin) { + console.log(` [codex] keeping upstream ${codexBinName}`); + } else if (vendorCodex) { // For Linux: put codex in sourceDir (mac-x64/) so it can be found, // but also mark for later copy to forge output. const dest = path.join(sourceDir, codexBinName); diff --git a/scripts/sync-upstream.js b/scripts/sync-upstream.js index f0eaca36..d9788a2f 100644 --- a/scripts/sync-upstream.js +++ b/scripts/sync-upstream.js @@ -6,7 +6,7 @@ * src/{platform}/ * _asar/ Extracted app.asar content (patch target) * app.asar.unpacked/ Native modules (kept as-is from upstream) - * codex|codex.exe CLI binary (will be replaced by @cometix/codex) + * codex|codex.exe CLI binary (Windows keeps upstream; Linux uses @cometix/codex later) * rg|rg.exe ripgrep binary (kept from upstream) * plugins/ Bundled plugins * native/ Platform native modules @@ -21,7 +21,7 @@ const tls = require("tls"); const http = require("http"); const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); // TLS certs for MS delivery CDN const certsDir = path.join(__dirname, "certs"); @@ -80,6 +80,25 @@ function extractArchive(archive, dest) { if (fs.readdirSync(dest).length > 0) return; } } + + if (process.platform === "win32" && /\.(msix|zip)$/i.test(archive)) { + try { + execFileSync("powershell", [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-Command", + "& { param($archive, $dest) " + + "Add-Type -AssemblyName System.IO.Compression.FileSystem; " + + "[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $dest) }", + archive, + dest, + ], { stdio: "pipe" }); + return; + } catch { + if (fs.readdirSync(dest).length > 0) return; + } + } + throw new Error(`Failed to extract ${archive}`); } } @@ -93,7 +112,31 @@ function findFile(dir, name) { return null; } +function findExistingPathCaseInsensitive(p) { + if (fs.existsSync(p)) return p; + + const parsed = path.parse(p); + let current = parsed.root; + const rest = path.relative(parsed.root, p); + if (!rest || rest.startsWith("..")) return p; + + for (const part of rest.split(/[\\/]+/)) { + if (!part) continue; + let entries; + try { + entries = fs.readdirSync(current); + } catch { + return p; + } + const match = entries.find((entry) => entry.toLowerCase() === part.toLowerCase()); + if (!match) return p; + current = path.join(current, match); + } + return fs.existsSync(current) ? current : p; +} + function copyRecursive(src, dest) { + src = findExistingPathCaseInsensitive(src); fs.mkdirSync(dest, { recursive: true }); let count = 0; for (const e of fs.readdirSync(src, { withFileTypes: true })) { @@ -119,6 +162,103 @@ function countFiles(dir) { return n; } +function encodeScopedPackagePath(relPath) { + return relPath + .split(/[\\/]+/) + .map((part) => part.startsWith("@") ? `%40${part.slice(1)}` : part) + .join(path.sep); +} + +function resolveUnpackedFile(unpackedRoot, relPath) { + const direct = path.join(unpackedRoot, relPath); + if (fs.existsSync(direct)) return direct; + + const directCase = findExistingPathCaseInsensitive(direct); + if (fs.existsSync(directCase)) return directCase; + + // Windows Store MSIX extraction can percent-encode scoped package folders: + // @worklouder -> %40worklouder + // @serialport -> %40serialport + const encoded = path.join(unpackedRoot, encodeScopedPackagePath(relPath)); + if (fs.existsSync(encoded)) return encoded; + + const encodedCase = findExistingPathCaseInsensitive(encoded); + return fs.existsSync(encodedCase) ? encodedCase : null; +} + +function extractAsarForPatching(asarPath, asarDest) { + const asar = require("@electron/asar"); + const { header, headerSize } = asar.getRawHeader(asarPath); + const fd = fs.openSync(asarPath, "r"); + const unpackedRoot = `${asarPath}.unpacked`; + let packedCount = 0; + let unpackedCount = 0; + let unpackedMissingCount = 0; + + // ASAR layout: 8-byte pickle header + header JSON + packed file payload. + const dataStart = 8 + headerSize; + + try { + const visit = (node, relPath) => { + if (node.files) { + fs.mkdirSync(path.join(asarDest, relPath), { recursive: true }); + for (const [name, child] of Object.entries(node.files)) { + visit(child, path.join(relPath, name)); + } + return; + } + + const dest = path.join(asarDest, relPath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + + if (node.link) { + const linkTarget = path.join(asarDest, node.link); + if (path.relative(asarDest, linkTarget).startsWith("..")) { + throw new Error(`ASAR link escapes output: ${relPath} -> ${node.link}`); + } + try { fs.unlinkSync(dest); } catch {} + fs.symlinkSync(path.relative(path.dirname(dest), linkTarget), dest); + return; + } + + const size = Number(node.size || 0); + if (node.unpacked) { + const unpackedFile = resolveUnpackedFile(unpackedRoot, relPath); + if (unpackedFile) { + fs.copyFileSync(unpackedFile, dest); + unpackedCount++; + } else { + // Keep the tree complete so patch scripts can still run, but surface + // the mismatch in the summary. + fs.writeFileSync(dest, Buffer.alloc(0)); + unpackedMissingCount++; + } + } else if (size <= 0) { + fs.writeFileSync(dest, Buffer.alloc(0)); + packedCount++; + } else { + const buf = Buffer.alloc(size); + fs.readSync(fd, buf, 0, size, dataStart + Number(node.offset || 0)); + fs.writeFileSync(dest, buf); + packedCount++; + } + + if (node.executable) { + try { fs.chmodSync(dest, 0o755); } catch {} + } + }; + + visit({ files: header.files }, ""); + } finally { + fs.closeSync(fd); + } + + console.log(` [asar extract] ${packedCount} packed files, ${unpackedCount} unpacked files`); + if (unpackedMissingCount > 0) { + console.log(` [!] ${unpackedMissingCount} unpacked files were missing and replaced with stubs`); + } +} + // ─── Version detection ────────────────────────────────────────── async function getAppcastVersion(url) { @@ -223,7 +363,7 @@ function assembleOutput(resourcesDir, destDir, label) { // 1. Extract app.asar → _asar/ (for patching) const asarDest = path.join(destDir, "_asar"); console.log(" [asar extract] -> _asar/"); - execSync(`npx asar extract "${asarPath}" "${asarDest}"`); + extractAsarForPatching(asarPath, asarDest); // 2. Copy app.asar.unpacked/ as-is (native modules) const unpackedSrc = path.join(resourcesDir, "app.asar.unpacked");