#!/bin/sh # Faxin Skill installer. No npm, no sudo, no credentials. set -eu umask 077 SITE='https://faxin.david-legal.com' export FAXIN_RELEASE_ORIGIN="${FAXIN_RELEASE_ORIGIN:-$SITE}" case "$(uname -s)" in Darwin) SYSTEM=darwin ;; Linux) SYSTEM=linux ;; *) echo '目前支持 macOS / Linux。Windows 尚未验证。' >&2; exit 1;; esac case "$(uname -m)" in arm64|aarch64) ARCH=arm64 ;; x86_64|amd64) ARCH=x64 ;; *) echo '不支持当前处理器架构。' >&2; exit 1;; esac export FAXIN_PLATFORM="$SYSTEM-$ARCH" NODE_BIN="${FAXIN_NODE:-$(command -v node || true)}" if [ -z "$NODE_BIN" ] || ! "$NODE_BIN" -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||a===22&&b>=13?0:1)' 2>/dev/null; then command -v curl >/dev/null || { echo '请先安装 curl。' >&2; exit 1; } RUNTIME="${FAXIN_RUNTIME_ROOT:-$HOME/.local/share/faxin-skill}/node-v24.16.0-$SYSTEM-$ARCH" if [ ! -x "$RUNTIME/bin/node" ]; then echo '正在安装法信 Skill 所需的运行环境,仅写入你的用户目录。' case "$SYSTEM-$ARCH" in darwin-arm64) EXPECTED=39189dab4eeb15706c424af0ac08a3044c9e48f7db12a7d77f6b7aafc7dd5df6 ;; darwin-x64) EXPECTED=298b4c7b3cb80765c8703e42b90324a4ece3b6634947b89e769c3c980ab55185 ;; linux-arm64) EXPECTED=589f5b6dd4fcfee4dfda73013903c966abaa8abd93dbc9d436544e472b4f0e74 ;; linux-x64) EXPECTED=2faf6a387e9b62b888e21c54f01249fb27537ffecf1842f29f4c919d0a59a0ff ;; esac WORK=$(mktemp -d) trap 'rm -rf "$WORK"' EXIT HUP INT TERM curl --fail --location --silent --show-error --proto '=https' --tlsv1.2 --connect-timeout 20 --max-time 300 "https://nodejs.org/dist/v24.16.0/node-v24.16.0-$SYSTEM-$ARCH.tar.gz" -o "$WORK/node.tar.gz" if command -v shasum >/dev/null; then ACTUAL=$(shasum -a 256 "$WORK/node.tar.gz" | cut -d ' ' -f 1); else ACTUAL=$(sha256sum "$WORK/node.tar.gz" | cut -d ' ' -f 1); fi [ "$ACTUAL" = "$EXPECTED" ] || { echo '运行环境校验失败,已停止。' >&2; exit 1; } tar -xzf "$WORK/node.tar.gz" -C "$WORK" mkdir -p "$(dirname "$RUNTIME")" mv "$WORK/node-v24.16.0-$SYSTEM-$ARCH" "$RUNTIME" rm -rf "$WORK" trap - EXIT HUP INT TERM fi NODE_BIN="$RUNTIME/bin/node" fi "$NODE_BIN" --input-type=module - "$@" <<'FAXIN_NODE_INSTALLER' import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; const args = process.argv.slice(2); let client = "codex", bundle = null, dest = null; for (let i = 0; i < args.length; i++) { if (args[i] === "--client") client = args[++i]; else if (args[i] === "--bundle") bundle = path.resolve(args[++i]); else if (args[i] === "--dest") dest = path.resolve(args[++i]); else if (args[i] === "--help") { console.log( "法信 Skill 安装 / Install\n--client codex|workbuddy|claude 选择 AI 客户端\n--dest PATH 自定义 Skill 目录\n已有版本会自动备份;不会更改登录信息。", ); process.exit(0); } else throw new Error("未知参数 / Unknown option: " + args[i]); } if (!["codex", "workbuddy", "claude"].includes(client)) throw new Error("支持的客户端 / Supported clients: codex, workbuddy, claude"); if (!["darwin", "linux", "win32"].includes(process.platform)) throw new Error("目前支持 Windows 11、macOS 和 Linux。"); if ( process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime ) throw new Error("Linux 安装包需要 glibc;暂不支持 Alpine / musl。"); const platform = process.platform + "-" + process.arch; const windows = process.platform === "win32"; function windowsPS(script, env = {}) { const program = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return execFileSync(program, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from("$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); " + script, 'utf16le').toString('base64')], {encoding:'utf8', windowsHide:true, env:{...process.env,...env}, maxBuffer:4*1024*1024, timeout:120000}); } const sha = (b) => crypto.createHash("sha256").update(b).digest("hex"); const temp = fs.mkdtempSync(path.join(os.tmpdir(), "faxin-install-")); let stage, backup, destination; try { if (!bundle) { const origin = new URL(process.env.FAXIN_RELEASE_ORIGIN); if ( origin.protocol !== "https:" && !( process.env.FAXIN_INSTALL_TEST === "1" && origin.hostname === "127.0.0.1" ) ) throw new Error("下载地址必须为 HTTPS。"); const fetchBytes = async (p, limit) => { const u = new URL(p, origin); if (u.origin !== origin.origin) throw new Error("下载来源不一致。"); const r = await fetch(u, { redirect: "error", signal: AbortSignal.timeout(180000), }); if (!r.ok) throw new Error("下载失败 / Download failed: HTTP " + r.status); const chunks = []; let size = 0; for await (const chunk of r.body) { size += chunk.length; if (size > limit) throw new Error("下载大小超过限制。"); chunks.push(chunk); } return Buffer.concat(chunks); }; const release = JSON.parse(await fetchBytes("/release.json", 128 * 1024)); const artifact = release.platforms[platform]; if (!artifact) throw new Error("当前系统尚不支持。"); console.log("正在下载法信 Skill " + release.version + " / Downloading…"); const data = await fetchBytes(artifact.url, 25 * 1024 * 1024); if (sha(data) !== artifact.sha256) throw new Error("安装包校验失败,未安装。"); const zip = path.join(temp, "skill.zip"); fs.writeFileSync(zip, data); const zipEnv = {FAXIN_ZIP_FILE:zip, FAXIN_ZIP_DEST:temp}; const entries = windows ? JSON.parse(windowsPS("Add-Type -AssemblyName System.IO.Compression.FileSystem; $zip=[IO.Compression.ZipFile]::OpenRead($env:FAXIN_ZIP_FILE); try { ConvertTo-Json -Compress -InputObject @($zip.Entries | ForEach-Object { $_.FullName }) } finally { $zip.Dispose() }", zipEnv)) : execFileSync("unzip", ["-Z1", zip], { encoding: "utf8" }).trim().split("\n"); if ( entries.some( (f) => !f.startsWith("faxin-cli/") || f.split("/").includes("..") || f.includes("\\") || f.includes(":"), ) ) throw new Error("安装包路径无效。"); if (windows) windowsPS("Add-Type -AssemblyName System.IO.Compression.FileSystem; [IO.Compression.ZipFile]::ExtractToDirectory($env:FAXIN_ZIP_FILE,$env:FAXIN_ZIP_DEST)", zipEnv); else execFileSync("unzip", ["-q", zip, "-d", temp]); bundle = path.join(temp, "faxin-cli"); } const manifest = JSON.parse( fs.readFileSync(path.join(bundle, "bundle.json"), "utf8"), ); if (manifest.platform !== platform) throw new Error( "安装包与本机架构不符,请重新下载 / Incorrect package for this computer.", ); for (const [file, hash] of Object.entries(manifest.files)) { if ( path.isAbsolute(file) || file.split("/").includes("..") || file.includes("\\") || file.includes(":") ) throw new Error("Invalid manifest path"); const p = path.join(bundle, file); if (fs.lstatSync(p).isSymbolicLink() || sha(fs.readFileSync(p)) !== hash) throw new Error("安装文件校验失败: " + file); } const config = { codex: process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), workbuddy: process.env.WORKBUDDY_CONFIG_DIR?.trim() || path.join(os.homedir(), ".workbuddy"), claude: process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), }[client]; destination = dest || path.join(config, "skills", "faxin-cli"); if ( destination === bundle || destination.startsWith(bundle + path.sep) || bundle.startsWith(destination + path.sep) ) throw new Error("安装目录与来源目录重叠。"); fs.mkdirSync(path.dirname(destination), { recursive: true }); stage = fs.mkdtempSync(path.join(path.dirname(destination), ".faxin-stage-")); // Copy only files listed in the verified release manifest. for (const file of Object.keys(manifest.files)) { const to = path.join(stage, file); fs.mkdirSync(path.dirname(to), { recursive: true }); fs.copyFileSync(path.join(bundle, file), to); } const quote = (s) => "'" + s.replaceAll("'", "'\\''") + "'"; fs.mkdirSync(path.join(stage, "bin"), { recursive: true }); const launcher = windows ? 'faxin.cmd' : 'faxin'; if (windows) { // A relative ASCII launcher also works in Chinese/space-containing profile paths. fs.mkdirSync(path.join(stage,'runtime')); const runtime = path.join(stage,'runtime','node.exe'); try { fs.linkSync(process.execPath,runtime); } catch { fs.copyFileSync(process.execPath,runtime); } fs.writeFileSync(path.join(stage,'bin',launcher), '@echo off\r\n"%~dp0..\\runtime\\node.exe" "%~dp0..\\scripts\\faxin.mjs" %*\r\nexit /b %errorlevel%\r\n'); } else fs.writeFileSync( path.join(stage, "bin", "faxin"), "#!/bin/sh\nexec " + quote(process.execPath) + " " + quote(path.join(destination, "scripts", "faxin.mjs")) + ' "$@"\n', { mode: 0o755 }, ); // Teach the agent to use the verified runtime even when Node is absent from PATH. const skill = path.join(stage, "SKILL.md"); fs.writeFileSync( skill, fs.readFileSync(skill, "utf8") + "\n## 本机启动方式 / Local launcher\n\n安装器已配置运行环境。调用 `" + path.join(destination, "bin", launcher) + "`;不需要安装依赖。Windows PowerShell 调用带空格的路径时用 `& \"路径\\bin\\faxin.cmd\"`。\n", ); execFileSync( process.execPath, [path.join(stage, "scripts", "faxin.mjs"), "help"], { stdio: "ignore" }, ); // A text-only PDF check also loads the native canvas dependency before activation. execFileSync( process.execPath, [ "--input-type=module", "-e", "await import('pdfjs-dist/legacy/build/pdf.mjs')", ], { cwd: stage, stdio: "pipe" }, ); if (fs.existsSync(destination)) { backup = destination + ".backup-" + Date.now(); fs.renameSync(destination, backup); } fs.renameSync(stage, destination); stage = null; console.log( "\n安装完成 / Installed: " + destination + "\n在 AI 客户端中开始一个新对话,然后说:“使用 faxin-cli 查询……”\n首次登录时,请在本机页面输入自己的上海律协账号。不要在聊天中发送密码。", ); if (backup) console.log("旧版本备份 / Backup: " + backup); console.log("命令入口 / CLI: " + path.join(destination, "bin", launcher)); } catch (e) { if (stage) fs.rmSync(stage, { recursive: true, force: true }); if (backup && !fs.existsSync(destination)) fs.renameSync(backup, destination); console.error("安装停止 / Installation stopped: " + e.message); process.exitCode = 1; } finally { fs.rmSync(temp, { recursive: true, force: true }); } FAXIN_NODE_INSTALLER