#!/usr/bin/env node // FreeApply installer for Claude Desktop, Claude Code, Cursor, VS Code and Windsurf. (Claude Desktop users without // Node.js install the .mcpb from the website instead.) // Published at https://www.mybotsai.com/appserver/install.mjs; install.ps1 and install.sh download and run it. // // node install.mjs find your AI apps and ask before adding FreeApply to each // node install.mjs --yes add it to every app found, without asking // node install.mjs --apps cursor,vscode // node install.mjs --uninstall remove FreeApply from your apps and delete the program files // (your job-search data in ~/.freeapply is kept) // // What it does: downloads the signed package, checks its Ed25519 signature against the key below and its SHA-256, // unpacks it into a program folder, and adds a "freeapply" server to each app's MCP settings (a backup of each // settings file is kept next to it). It never asks for passwords and sends nothing about you anywhere. import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import readline from "node:readline/promises"; import { spawnSync } from "node:child_process"; import zlib from "node:zlib"; const BASE = process.env.FREEAPPLY_BASE || "https://www.mybotsai.com/appserver"; // Release public keys, written in by tools/release.mjs when the installer is published. const KEYS = {"rel1":"W5ca4E28tIhyCaXTddtDjKbThy8Nivly/O0Rab68WE8="}; const NAME = "freeapply"; const MIN_NODE = [22, 13]; const home = os.homedir(); const win = process.platform === "win32", mac = process.platform === "darwin"; const say = (m = "") => console.log(m); const die = (m) => { console.error(`\nFreeApply: ${m}`); process.exit(1); }; const args = process.argv.slice(2); const flag = (n) => args.includes(`--${n}`); const opt = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : (args.find((a) => a.startsWith(`--${n}=`)) || "").split("=")[1]; }; const [maj, min] = process.versions.node.split(".").map(Number); if (maj < MIN_NODE[0] || (maj === MIN_NODE[0] && min < MIN_NODE[1])) die(`Node.js ${MIN_NODE.join(".")} or newer is needed (this is ${process.versions.node}). Get it from https://nodejs.org`); const programDir = win ? path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "FreeApply") : mac ? path.join(home, "Library", "Application Support", "FreeApply") : path.join(process.env.XDG_DATA_HOME || path.join(home, ".local", "share"), "freeapply"); const appData = win ? process.env.APPDATA || path.join(home, "AppData", "Roaming") : mac ? path.join(home, "Library", "Application Support") : process.env.XDG_CONFIG_HOME || path.join(home, ".config"); // ─── signature check (same envelope as the app's signed workflow) ─── function verify(raw) { const env = JSON.parse(raw); const x = KEYS[env.key_id]; if (!x) throw new Error("signed with an unknown key"); const pub = crypto.createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(x, "base64").toString("base64url") }, format: "jwk" }); const payload = Buffer.from(env.payload, "base64"), sig = Buffer.from(env.signature, "base64"); if (sig.length !== 64 || !crypto.verify(null, payload, pub, sig)) throw new Error("signature does not match"); return JSON.parse(payload.toString("utf8")); } async function get(url, limit) { const r = await fetch(url, { headers: { "User-Agent": "FreeApply-installer" }, signal: AbortSignal.timeout(60000) }); if (!r.ok) throw new Error(`${url}: HTTP ${r.status}`); const b = Buffer.from(await r.arrayBuffer()); if (b.length > limit) throw new Error(`${url}: too large`); return b; } // ─── unzip (the .mcpb is a plain ZIP); refuses any entry that would land outside the target folder ─── function unzip(buf, dest) { let eocd = -1; for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } if (eocd < 0) throw new Error("not a zip file"); const count = buf.readUInt16LE(eocd + 10); let p = buf.readUInt32LE(eocd + 16); const root = path.resolve(dest); for (let n = 0; n < count; n++) { if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error("bad zip directory"); const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), usize = buf.readUInt32LE(p + 24); const nlen = buf.readUInt16LE(p + 28), xlen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32), off = buf.readUInt32LE(p + 42); const name = buf.toString("utf8", p + 46, p + 46 + nlen); p += 46 + nlen + xlen + clen; const out = path.resolve(root, name); if (!out.startsWith(root + path.sep) || path.isAbsolute(name) || name.includes("..")) throw new Error(`unsafe path in package: ${name}`); if (name.endsWith("/")) continue; const start = off + 30 + buf.readUInt16LE(off + 26) + buf.readUInt16LE(off + 28); const data = buf.subarray(start, start + csize); const file = method === 0 ? data : method === 8 ? zlib.inflateRawSync(data) : null; if (!file || file.length !== usize) throw new Error(`cannot unpack ${name}`); fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, file); } } // ─── the apps ─── // Runs a command. On Windows, CLIs like `claude` are .cmd files that only cmd.exe can start, so the quoted command line goes // to cmd.exe as is (no `shell` option: Node 24 warns about passing arguments that way). function run(cmd, args, stdio = "ignore") { if (!win) return spawnSync(cmd, args, { stdio }); const q = (x) => (/[\s"&|<>^()]/.test(x) ? `"${x.replace(/"/g, '""')}"` : x); return spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${[cmd, ...args].map(q).join(" ")}"`], { stdio, windowsVerbatimArguments: true }); } const has = (cmd) => run(cmd, ["--version"]).status === 0; // Claude Desktop keeps its settings in AppData\Roaming\Claude; a Microsoft Store install that is virtualized keeps them // in its package folder instead. Use whichever settings file exists, else the normal place. function claudeDir() { if (win) { const pkgs = path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages"); const store = fs.existsSync(pkgs) ? fs.readdirSync(pkgs).filter((d) => /^Claude_/.test(d)) .map((d) => path.join(pkgs, d, "LocalCache", "Roaming", "Claude")).find((d) => fs.existsSync(path.join(d, "claude_desktop_config.json"))) : null; return store || path.join(appData, "Claude"); } return path.join(appData, "Claude"); } // Claude Code: the `claude` command when it is on PATH (npm or standalone install), otherwise the copy the Claude // desktop app bundles in /claude-code///claude(.exe), newest version first. let cli; function claudeCli() { if (cli !== undefined) return cli; if (has("claude")) return (cli = "claude"); const root = path.join(claudeDir(), "claude-code"); const list = (d) => { try { return fs.readdirSync(d); } catch { return []; } }; const newer = (a, b) => { const x = a.split(".").map(Number), y = b.split(".").map(Number); for (let i = 0; i < 4; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0); return false; }; let best = null; for (const v of list(root).filter((v) => /^\d+(\.\d+)*$/.test(v))) { for (const b of list(path.join(root, v))) { const exe = path.join(root, v, b, win ? "claude.exe" : "claude"); if (fs.existsSync(exe) && (!best || newer(v, best.v))) best = { v, exe }; } } return (cli = best ? best.exe : null); } const APPS = [ { id: "claude-desktop", name: "Claude Desktop", found: () => fs.existsSync(claudeDir()), file: () => path.join(claudeDir(), "claude_desktop_config.json"), key: "mcpServers", restart: "Quit Claude completely (right-click its icon by the clock → Quit) and open it again." }, { id: "claude-code", name: "Claude Code", found: () => !!claudeCli(), kind: "cli" }, { id: "cursor", name: "Cursor", found: () => fs.existsSync(path.join(home, ".cursor")), file: () => path.join(home, ".cursor", "mcp.json"), key: "mcpServers" }, { id: "vscode", name: "VS Code", found: () => fs.existsSync(path.join(appData, "Code", "User")), file: () => path.join(appData, "Code", "User", "mcp.json"), key: "servers", vscode: true }, { id: "windsurf", name: "Windsurf", found: () => fs.existsSync(path.join(home, ".codeium", "windsurf")), file: () => path.join(home, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers" }, ]; function entry(app, main) { const e = { command: process.execPath, args: [main] }; return app.vscode ? { type: "stdio", ...e } : e; } /** Adds or removes our server in an app's JSON settings. Files that are not plain JSON (comments) are left alone. */ function editJson(app, main, remove = false) { const file = app.file(); let cfg = {}; if (fs.existsSync(file)) { const text = fs.readFileSync(file, "utf8"); try { cfg = text.trim() ? JSON.parse(text) : {}; } catch { say(` ${app.name}: ${file} has comments or is not plain JSON, so it was not changed. Add this under "${app.key}":`); say(` "${NAME}": ${JSON.stringify(entry(app, main))}`); return false; } fs.copyFileSync(file, `${file}.bak-freeapply`); } cfg[app.key] = cfg[app.key] || {}; if (remove) delete cfg[app.key][NAME]; else cfg[app.key][NAME] = entry(app, main); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n"); return true; } function claudeCode(main, remove = false) { const c = claudeCli(); if (!c) return false; run(c, ["mcp", "remove", NAME, "-s", "user"]); if (remove) return true; const skills = path.join(home, ".claude", "skills"); return run(c, ["mcp", "add", "-s", "user", NAME, "--", process.execPath, main, "--skills-dir", skills], "inherit").status === 0; } /** True when FreeApply is also installed as a Claude Desktop extension (.mcpb), which would run a second copy. */ function claudeExtensionInstalled() { const dir = path.join(claudeDir(), "Claude Extensions"); try { return fs.readdirSync(dir).some((d) => /freeapply/i.test(d)); } catch { return false; } } async function main() { say("FreeApply installer · https://www.mybotsai.com"); const wanted = (opt("apps") || "").split(",").filter(Boolean); const asked = wanted.length ? APPS.filter((a) => wanted.includes(a.id)) : APPS; const pick = asked.filter((a) => a.found()); if (flag("uninstall")) { const cur = path.join(programDir, "current", "src", "main.js"); for (const a of pick) { if (a.kind === "cli") claudeCode(cur, true) && say(` removed from ${a.name}`); else if (a.file) editJson(a, cur, true) && say(` removed from ${a.name}`); } fs.rmSync(programDir, { recursive: true, force: true }); say(`Program files removed. Your job-search data is still in ${path.join(home, ".freeapply")}; delete that folder to remove it too.`); return; } if (!Object.keys(KEYS).length) die("this copy of the installer has no release key; download it again from https://www.mybotsai.com/appserver/"); say("1/3 Downloading the signed package…"); const latest = JSON.parse((await get(`${BASE}/latest.json`, 64 * 1024)).toString("utf8")); if (!/^freeapply-[\d.]+\.mcpb$/.test(latest.file || "")) die("the download list looks wrong; try again later"); const [pkg, sigRaw] = await Promise.all([get(`${BASE}/${latest.file}`, 50e6), get(`${BASE}/${latest.file}.sig.json`, 64 * 1024)]); let signed; try { signed = verify(sigRaw.toString("utf8")); } catch (e) { die(`the package signature failed (${e.message}). Nothing was installed.`); } const sha = crypto.createHash("sha256").update(pkg).digest("hex"); if (signed.kind !== "release" || signed.file !== latest.file || signed.sha256 !== sha) die("the package does not match its signature. Nothing was installed."); say(` FreeApply ${signed.version}, signature OK (key ${JSON.parse(sigRaw).key_id}), sha256 ${sha.slice(0, 16)}…`); say("2/3 Unpacking…"); const target = path.join(programDir, signed.version); const tmp = `${target}.tmp`; fs.rmSync(tmp, { recursive: true, force: true }); unzip(pkg, tmp); fs.rmSync(target, { recursive: true, force: true }); fs.renameSync(tmp, target); // "current" is a copy, not a link (links need extra rights on Windows); app settings point at it so updates only // replace this folder. const cur = path.join(programDir, "current"); fs.rmSync(cur, { recursive: true, force: true }); fs.cpSync(target, cur, { recursive: true }); for (const d of fs.readdirSync(programDir)) if (d !== "current" && d !== signed.version) fs.rmSync(path.join(programDir, d), { recursive: true, force: true }); const main = path.join(cur, "src", "main.js"); say(` ${cur}`); say("3/3 Adding FreeApply to your AI apps…"); if (!pick.length) say(" No supported AI app was found. Install one (Claude, Cursor, VS Code, Windsurf), then run this again."); const missing = asked.filter((a) => !pick.includes(a)); if (missing.length) say(` Not found on this computer: ${missing.map((a) => a.name).join(", ")}. Install one later and run this again to add it.`); const ask = !flag("yes") && process.stdin.isTTY; const rl = ask ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null; const restarts = []; for (const a of pick) { if (rl && !/^(y|yes|)$/i.test((await rl.question(` Add FreeApply to ${a.name}? [Y/n] `)).trim())) continue; if (a.kind === "cli") { say(` ${a.name}: ${claudeCode(main) ? "added (user scope)" : "could not run `claude mcp add`; see https://www.mybotsai.com/install/"}`); } else if (editJson(a, main)) { say(` ${a.name}: added to ${a.file()}`); if (a.restart) restarts.push(`${a.name}: ${a.restart}`); if (a.id === "claude-desktop" && claudeExtensionInstalled()) { say(" Note: FreeApply is also installed as a Claude Desktop extension. Remove one of the two (Settings → Extensions) so only one copy runs."); } } } rl?.close(); say("\nDone. Restart your AI app, then say: \"Set up FreeApply\"."); for (const r of restarts) say(` ${r}`); say("Your dashboard will open at http://127.0.0.1:7878 . Uninstall any time: node install.mjs --uninstall"); } main().catch((e) => die(e.message));