From f51e89ce120be0c5b12e01c8bdbaf699c44fbbae Mon Sep 17 00:00:00 2001 From: canghe Date: Sat, 4 Apr 2026 12:36:50 +0800 Subject: [PATCH] Add npm distribution support with PyInstaller binary - Add npm package structure (@canghe_ai/wechat-cli) with platform-specific optionalDependencies - Add JS wrapper (bin/wechat-cli.js) and postinstall script - Add PyInstaller entry point and build script - Update scanner_macos.py for PyInstaller compatibility (sys._MEIPASS) - Update README with npm install instructions (macOS arm64) - Fix repo URLs to freestylefly/wechat-cli --- .gitignore | 7 ++ README.md | 12 +- README_CN.md | 12 +- entry.py | 5 + npm/platforms/darwin-arm64/package.json | 12 ++ npm/platforms/darwin-x64/package.json | 12 ++ npm/platforms/linux-arm64/package.json | 12 ++ npm/platforms/linux-x64/package.json | 12 ++ npm/platforms/win32-x64/package.json | 12 ++ npm/scripts/build.py | 139 ++++++++++++++++++++++++ npm/wechat-cli/bin/wechat-cli.js | 55 ++++++++++ npm/wechat-cli/install.js | 36 ++++++ npm/wechat-cli/package.json | 30 +++++ wechat_cli/keys/scanner_macos.py | 15 ++- 14 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 entry.py create mode 100644 npm/platforms/darwin-arm64/package.json create mode 100644 npm/platforms/darwin-x64/package.json create mode 100644 npm/platforms/linux-arm64/package.json create mode 100644 npm/platforms/linux-x64/package.json create mode 100644 npm/platforms/win32-x64/package.json create mode 100644 npm/scripts/build.py create mode 100644 npm/wechat-cli/bin/wechat-cli.js create mode 100644 npm/wechat-cli/install.js create mode 100644 npm/wechat-cli/package.json diff --git a/.gitignore b/.gitignore index c620895..24eecf2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ __pycache__/ dist/ build/ +# PyInstaller +*.spec + +# npm platform binaries (published to npm, not git) +npm/platforms/*/bin/ + # Virtual environments .venv/ venv/ @@ -31,6 +37,7 @@ Thumbs.db # Sensitive data — NEVER commit *.json !pyproject.toml +!npm/**/package.json # Temp files *.tmp diff --git a/README.md b/README.md index d313569..7aa1f39 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,24 @@ A command-line tool to query your local WeChat data — chat history, contacts, ### Install +**Via pip (requires Python >= 3.10):** + ```bash pip install wechat-cli ``` +**Via npm (standalone binary, no Python needed):** + +```bash +npm install -g @canghe_ai/wechat-cli +``` + +> Currently only **macOS arm64** binary is available. Other platforms (macOS Intel, Linux, Windows) can use the pip install method. PRs with additional platform binaries are welcome. + Or install from source: ```bash -git clone https://github.com/canghe/wechat-cli.git +git clone https://github.com/freestylefly/wechat-cli.git cd wechat-cli pip install -e . ``` diff --git a/README_CN.md b/README_CN.md index 591a23b..569fa3d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -19,14 +19,24 @@ ### 安装 +**通过 pip(需要 Python >= 3.10):** + ```bash pip install wechat-cli ``` +**通过 npm(独立二进制,无需 Python):** + +```bash +npm install -g @canghe_ai/wechat-cli +``` + +> 目前仅提供 **macOS arm64** 二进制。其他平台(macOS Intel、Linux、Windows)可使用 pip 安装。欢迎提交其他平台的二进制 PR。 + 或从源码安装: ```bash -git clone https://github.com/canghe/wechat-cli.git +git clone https://github.com/freestylefly/wechat-cli.git cd wechat-cli pip install -e . ``` diff --git a/entry.py b/entry.py new file mode 100644 index 0000000..5ad59c5 --- /dev/null +++ b/entry.py @@ -0,0 +1,5 @@ +"""PyInstaller entry point — avoids relative import issues.""" +from wechat_cli.main import cli + +if __name__ == "__main__": + cli() diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..1e5da65 --- /dev/null +++ b/npm/platforms/darwin-arm64/package.json @@ -0,0 +1,12 @@ +{ + "name": "@canghe_ai/wechat-cli-darwin-arm64", + "version": "0.2.0", + "description": "wechat-cli binary for macOS arm64", + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["bin/"], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json new file mode 100644 index 0000000..91cda9e --- /dev/null +++ b/npm/platforms/darwin-x64/package.json @@ -0,0 +1,12 @@ +{ + "name": "@canghe_ai/wechat-cli-darwin-x64", + "version": "0.2.0", + "description": "wechat-cli binary for macOS x64", + "os": ["darwin"], + "cpu": ["x64"], + "files": ["bin/"], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/linux-arm64/package.json b/npm/platforms/linux-arm64/package.json new file mode 100644 index 0000000..c8d0fe8 --- /dev/null +++ b/npm/platforms/linux-arm64/package.json @@ -0,0 +1,12 @@ +{ + "name": "@canghe_ai/wechat-cli-linux-arm64", + "version": "0.2.0", + "description": "wechat-cli binary for Linux arm64", + "os": ["linux"], + "cpu": ["arm64"], + "files": ["bin/"], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/linux-x64/package.json b/npm/platforms/linux-x64/package.json new file mode 100644 index 0000000..f36465c --- /dev/null +++ b/npm/platforms/linux-x64/package.json @@ -0,0 +1,12 @@ +{ + "name": "@canghe_ai/wechat-cli-linux-x64", + "version": "0.2.0", + "description": "wechat-cli binary for Linux x64", + "os": ["linux"], + "cpu": ["x64"], + "files": ["bin/"], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/win32-x64/package.json b/npm/platforms/win32-x64/package.json new file mode 100644 index 0000000..52809d1 --- /dev/null +++ b/npm/platforms/win32-x64/package.json @@ -0,0 +1,12 @@ +{ + "name": "@canghe_ai/wechat-cli-win32-x64", + "version": "0.2.0", + "description": "wechat-cli binary for Windows x64", + "os": ["win32"], + "cpu": ["x64"], + "files": ["bin/"], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/scripts/build.py b/npm/scripts/build.py new file mode 100644 index 0000000..0e82932 --- /dev/null +++ b/npm/scripts/build.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Build wechat-cli standalone binaries with PyInstaller.""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +NPM_DIR = ROOT / "npm" +PLATFORMS_DIR = NPM_DIR / "platforms" + +PLATFORM_MAP = { + "darwin-arm64": {"target": "macos"}, + "darwin-x64": {"target": "macos"}, + "linux-x64": {"target": "linux"}, + "linux-arm64": {"target": "linux"}, + "win32-x64": {"target": "win"}, +} + + +def ensure_pyinstaller(): + try: + import PyInstaller # noqa: F401 + return + except ImportError: + pass + print("[+] Installing PyInstaller...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"]) + + +def build_platform(platform: str): + info = PLATFORM_MAP[platform] + os_name, arch = platform.split("-") + ext = ".exe" if os_name == "win32" else "" + binary_name = f"wechat-cli{ext}" + + output_dir = PLATFORMS_DIR / platform / "bin" + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n{'='*60}") + print(f"Building for {platform}...") + print(f"{'='*60}") + + cmd = [ + sys.executable, "-m", "PyInstaller", + "--onefile", + "--name", "wechat-cli", + "--distpath", str(output_dir), + "--workpath", str(ROOT / "build" / f"wechat-cli_{platform}"), + "--specpath", str(ROOT / "build"), + "--noconfirm", + "--clean", + ] + + # Bundle C binaries for key extraction + bin_dir = ROOT / "wechat_cli" / "bin" + if bin_dir.exists(): + for f in bin_dir.iterdir(): + if not f.name.startswith(".") and f.is_file(): + cmd.extend(["--add-binary", f"{f}:wechat_cli/bin"]) + + # Hidden imports + hidden = ["pysqlcipher3", "sqlcipher3", "Cryptodome", "zstandard"] + for h in hidden: + cmd.extend(["--hidden-import", h]) + + cmd.append(str(ROOT / "entry.py")) + + print(f"[+] Running: {' '.join(cmd)}") + + try: + subprocess.check_call(cmd, cwd=str(ROOT)) + except subprocess.CalledProcessError as e: + print(f"[-] Build failed for {platform}: {e}") + return False + + binary_path = output_dir / binary_name + if not binary_path.exists(): + print(f"[-] Binary not found: {binary_path}") + return False + + print(f"[+] Built: {binary_path}") + print(f" Size: {binary_path.stat().st_size / 1024 / 1024:.1f} MB") + return True + + +def main(): + if len(sys.argv) > 1: + platforms = sys.argv[1:] + else: + # Default: build for current platform only + import platform as _pf + current = f"{_pf.system().lower()}-{_pf.machine()}" + # Normalize + if current == "darwin-arm64": + platforms = ["darwin-arm64"] + elif current == "darwin-x86_64" or current == "darwin-amd64": + platforms = ["darwin-x64"] + else: + # Try to match + platforms = [] + for p in PLATFORM_MAP: + os_name, arch = p.split("-") + if os_name in current and (arch in current or + (arch == "x64" and ("x86_64" in current or "amd64" in current))): + platforms = [p] + break + if not platforms: + print(f"Cannot determine platform from '{current}'") + print(f"Usage: {sys.argv[0]} [platform...]") + print(f" Platforms: {', '.join(PLATFORM_MAP.keys())}") + sys.exit(1) + + print(f"[+] Building for: {', '.join(platforms)}") + ensure_pyinstaller() + + results = {} + for p in platforms: + if p not in PLATFORM_MAP: + print(f"[-] Unknown platform: {p}") + results[p] = False + continue + results[p] = build_platform(p) + + print(f"\n{'='*60}") + print("Build Summary:") + for p, ok in results.items(): + status = "OK" if ok else "FAILED" + print(f" {p}: {status}") + print(f"{'='*60}") + + if not all(results.values()): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/npm/wechat-cli/bin/wechat-cli.js b/npm/wechat-cli/bin/wechat-cli.js new file mode 100644 index 0000000..ed302b2 --- /dev/null +++ b/npm/wechat-cli/bin/wechat-cli.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +const { execFileSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@canghe_ai/wechat-cli-darwin-arm64', + 'darwin-x64': '@canghe_ai/wechat-cli-darwin-x64', + 'linux-x64': '@canghe_ai/wechat-cli-linux-x64', + 'linux-arm64': '@canghe_ai/wechat-cli-linux-arm64', + 'win32-x64': '@canghe_ai/wechat-cli-win32-x64', +}; + +const platformKey = `${process.platform}-${process.arch}`; +const ext = process.platform === 'win32' ? '.exe' : ''; + +function getBinaryPath() { + // 1. 环境变量覆盖 + if (process.env.WECHAT_CLI_BINARY) { + return process.env.WECHAT_CLI_BINARY; + } + + // 2. 从平台包解析 + const pkg = PLATFORM_PACKAGES[platformKey]; + if (!pkg) { + console.error(`wechat-cli: unsupported platform ${platformKey}`); + process.exit(1); + } + + try { + return require.resolve(`${pkg}/bin/wechat-cli${ext}`); + } catch { + // 3. fallback: 直接找 node_modules 下的路径 + const modPath = path.join( + path.dirname(require.resolve(`${pkg}/package.json`)), + `bin/wechat-cli${ext}` + ); + if (fs.existsSync(modPath)) return modPath; + } + + console.error(`wechat-cli: binary not found for ${platformKey}`); + console.error('Try: npm install --force @canghe/wechat-cli'); + process.exit(1); +} + +try { + execFileSync(getBinaryPath(), process.argv.slice(2), { + stdio: 'inherit', + env: { ...process.env }, + }); +} catch (e) { + if (e && e.status != null) process.exit(e.status); + throw e; +} diff --git a/npm/wechat-cli/install.js b/npm/wechat-cli/install.js new file mode 100644 index 0000000..2fa7888 --- /dev/null +++ b/npm/wechat-cli/install.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@canghe_ai/wechat-cli-darwin-arm64', + 'darwin-x64': '@canghe_ai/wechat-cli-darwin-x64', + 'linux-x64': '@canghe_ai/wechat-cli-linux-x64', + 'linux-arm64': '@canghe_ai/wechat-cli-linux-arm64', + 'win32-x64': '@canghe_ai/wechat-cli-win32-x64', +}; + +const platformKey = `${process.platform}-${process.arch}`; +const pkg = PLATFORM_PACKAGES[platformKey]; + +if (!pkg) { + console.log(`wechat-cli: no binary for ${platformKey}, skipping`); + process.exit(0); +} + +// Try to find and chmod the binary +const ext = process.platform === 'win32' ? '.exe' : ''; + +try { + const binaryPath = require.resolve(`${pkg}/bin/wechat-cli${ext}`); + if (process.platform !== 'win32') { + fs.chmodSync(binaryPath, 0o755); + console.log(`wechat-cli: set executable permission for ${platformKey}`); + } +} catch { + // Platform package was not installed (npm --no-optional or unsupported) + console.log(`wechat-cli: platform package ${pkg} not installed`); + console.log('To fix: npm install --force @canghe_ai/wechat-cli'); +} diff --git a/npm/wechat-cli/package.json b/npm/wechat-cli/package.json new file mode 100644 index 0000000..3729d19 --- /dev/null +++ b/npm/wechat-cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@canghe_ai/wechat-cli", + "version": "0.2.0", + "description": "WeChat data query CLI — chat history, contacts, sessions, favorites, and more. Designed for LLM integration.", + "bin": { + "wechat-cli": "bin/wechat-cli.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "files": [ + "bin/", + "install.js" + ], + "optionalDependencies": { + "@canghe_ai/wechat-cli-darwin-arm64": "0.2.0" + }, + "engines": { + "node": ">=14" + }, + "keywords": ["wechat", "cli", "wechat-cli", "llm", "ai"], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/freestylefly/wechat-cli" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/wechat_cli/keys/scanner_macos.py b/wechat_cli/keys/scanner_macos.py index a9818ff..b72abdb 100644 --- a/wechat_cli/keys/scanner_macos.py +++ b/wechat_cli/keys/scanner_macos.py @@ -18,9 +18,18 @@ def _find_binary(): else: raise RuntimeError(f"不支持的 macOS 架构: {machine}") - # 优先查找 bin/ 目录(pip 安装后位于包内) - pkg_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - bin_path = os.path.join(pkg_dir, "bin", name) + # PyInstaller 运行时:从临时解压目录查找 + if getattr(sys, 'frozen', False): + base = sys._MEIPASS + else: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + bin_path = os.path.join(base, "wechat_cli", "bin", name) + if os.path.isfile(bin_path): + return bin_path + + # fallback: 直接在 bin/ 下 + bin_path = os.path.join(base, "bin", name) if os.path.isfile(bin_path): return bin_path