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
This commit is contained in:
canghe
2026-04-04 12:36:50 +08:00
parent e64006bafe
commit f51e89ce12
14 changed files with 366 additions and 5 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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 .
```

View File

@@ -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 .
```

5
entry.py Normal file
View File

@@ -0,0 +1,5 @@
"""PyInstaller entry point — avoids relative import issues."""
from wechat_cli.main import cli
if __name__ == "__main__":
cli()

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

139
npm/scripts/build.py Normal file
View File

@@ -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()

View File

@@ -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;
}

36
npm/wechat-cli/install.js Normal file
View File

@@ -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');
}

View File

@@ -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"
}
}

View File

@@ -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