From cdca68ff3e5b742cdf17317a3c6da33ce06644ea Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Tue, 19 May 2026 16:56:50 +0800 Subject: [PATCH] fix(npm): improve missing binary diagnostics --- .github/workflows/release.yml | 205 +++++++++------------------------- README.md | 10 ++ README.zh-CN.md | 10 ++ npm/bin/cli.js | 83 +++++++++++--- npm/bin/install-skills.js | 0 npm/package.json | 3 +- npm/scripts/install.js | 32 ++++-- npm/test/cli.test.js | 52 +++++++++ npm/test/install.test.js | 53 +++++++++ scripts/build-npm.sh | 5 +- 10 files changed, 276 insertions(+), 177 deletions(-) mode change 100644 => 100755 npm/bin/install-skills.js create mode 100644 npm/test/cli.test.js create mode 100644 npm/test/install.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8be3183..531f3b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,171 +27,68 @@ jobs: run: | mkdir -p dist VERSION=${GITHUB_REF#refs/tags/v} - - for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64"; do - GOOS=$(echo $pair | cut -d' ' -f1) - GOARCH=$(echo $pair | cut -d' ' -f2) + MODULE="github.com/gitlink-org/gitlink-cli" + LDFLAGS="-s -w -X ${MODULE}/cmd.Version=${VERSION}" + + for pair in \ + "darwin amd64" \ + "darwin arm64" \ + "linux amd64" \ + "linux arm64" \ + "windows amd64" \ + "windows arm64"; do + GOOS=$(echo "$pair" | cut -d' ' -f1) + GOARCH=$(echo "$pair" | cut -d' ' -f2) + OUT="gitlink-cli" + if [ "$GOOS" = "windows" ]; then + OUT="gitlink-cli.exe" + fi + echo "Building ${GOOS}-${GOARCH}..." - GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o dist/gitlink-cli . - cd dist - tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" gitlink-cli - rm gitlink-cli - cd .. + BUILD_DIR="dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}" + mkdir -p "$BUILD_DIR" + CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "$LDFLAGS" -o "$BUILD_DIR/$OUT" . + + if [ "$GOOS" = "windows" ]; then + (cd "$BUILD_DIR" && zip -q "../gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "$OUT") + else + tar -czf "dist/gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" -C "$BUILD_DIR" "$OUT" + fi + rm -rf "$BUILD_DIR" done + ls -lh dist + - name: Create Release uses: softprops/action-gh-release@v2 with: - files: dist/*.tar.gz + files: | + dist/*.tar.gz + dist/*.zip generate_release_notes: true - name: Build npm package run: | VERSION=${GITHUB_REF#refs/tags/v} - mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills - - # Copy skills - cp -r skills/* npm-pkg/skills/ - - # Copy bin wrappers - cp bin/cli.js npm-pkg/bin/ 2>/dev/null || cat > npm-pkg/bin/cli.js << 'NODEEOF' - #!/usr/bin/env node - const {execFileSync} = require("child_process"); - const path = require("path"); - const bin = path.join(__dirname, "..", "bin", "gitlink-cli"); - try { process.exit(execFileSync(bin, process.argv.slice(2), {stdio:"inherit"}).status); } - catch(e) { process.exit(e.status || 1); } - NODEEOF - - cat > npm-pkg/bin/install-skills.js << 'NODEEOF' - #!/usr/bin/env node - const {execSync} = require("child_process"); - const path = require("path"); - const skillsDir = path.join(__dirname, "..", "skills"); - try { - execSync(`npx skills add "${skillsDir}" -y -g`, {stdio:"inherit"}); - } catch(e) { - console.error("Failed to install skills:", e.message); - process.exit(1); - } - NODEEOF - - # Copy install.js - cp scripts/install.js npm-pkg/scripts/ 2>/dev/null || cat > npm-pkg/scripts/install.js << 'NODEEOF' - #!/usr/bin/env node - "use strict"; - const os = require("os"); - const path = require("path"); - const fs = require("fs"); - const https = require("https"); - const http = require("http"); - const {execSync} = require("child_process"); - - const PACKAGE = require("../package.json"); - const VERSION = PACKAGE.version; - const BINARY_NAME = "gitlink-cli"; - const RELEASE_BASE = "https://github.com"; - const REPO_OWNER = "ccfos"; - const REPO_NAME = "gitlink-cli"; - - function getPlatformInfo() { - const platform = os.platform(); - const arch = os.arch(); - const pmap = {darwin:"darwin",linux:"linux",win32:"windows"}; - const amap = {x64:"amd64",arm64:"arm64"}; - const p=pmap[platform], a=amap[arch]; - if(!p||!a) throw new Error(`Unsupported: ${platform}-${arch}`); - return {platform:p,arch:a}; - } - - function fetch(url) { - return new Promise((resolve,reject) => { - const mod=url.startsWith("https")?https:http; - let count=0; - function req(u) { - if(++count>5) return reject(new Error("Too many redirects")); - mod.get(u,(res) => { - if([301,302,307,308].includes(res.statusCode)&&res.headers.location){ - let loc=res.headers.location; - if(loc.startsWith("/")){const p=new URL(u);loc=p.protocol+"//"+p.host+loc} - return req(loc); - } - if(res.statusCode!==200) return reject(new Error(`HTTP ${res.statusCode}`)); - const c=[];res.on("data",d=>c.push(d));res.on("end",()=>resolve(Buffer.concat(c))); - }).on("error",reject); - } - req(url); - }); - } - - async function main() { - try { - const {platform,arch} = getPlatformInfo(); - const binDir = path.join(__dirname,"..","bin"); - if(!fs.existsSync(binDir)) fs.mkdirSync(binDir,{recursive:true}); - const binaryPath = path.join(binDir,BINARY_NAME); - - if(fs.existsSync(binaryPath)) { - try { - const out = execSync(`"${binaryPath}" version`,{encoding:"utf-8",timeout:5000}); - if(out.includes(VERSION)) { console.log(`${BINARY_NAME} v${VERSION} already installed.`); return; } - } catch(e) {} - fs.unlinkSync(binaryPath); - } - - const assetName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`; - const url = `${RELEASE_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/v${VERSION}/${assetName}`; - console.log(`Downloading ${url}...`); - const data = await fetch(url); - const tmp = path.join(binDir,"dl.tar.gz"); - fs.writeFileSync(tmp,data); - execSync(`tar -xzf "${tmp}" -C "${binDir}"`,{stdio:"pipe"}); - fs.unlinkSync(tmp); - fs.chmodSync(binaryPath,0o755); - console.log(`${BINARY_NAME} v${VERSION} installed.`); - } catch(err) { - console.warn(`⚠ Binary download failed: ${err.message}`); - console.warn(`Skills are installed. Install binary manually:`); - console.warn(`npm run postinstall`); - } - } - main(); - NODEEOF - - # Create README - cp README.md npm-pkg/ - - # Create package.json - cat > npm-pkg/package.json << PKGEOF - { - "name": "@gitlink-ai/cli", - "version": "${VERSION}", - "description": "GitLink CLI — 面向 AI Agent 的 GitLink 命令行工具", - "main": "bin/cli.js", - "bin": { - "gitlink-cli": "./bin/cli.js", - "gitlink-cli-install-skills": "./bin/install-skills.js" - }, - "scripts": { - "postinstall": "node scripts/install.js" - }, - "files": [ - "bin/", - "scripts/", - "skills/", - "README.md", - "package.json" - ], - "repository": { - "type": "git", - "url": "https://github.com/ccfos/gitlink-cli.git" - }, - "keywords": ["gitlink", "cli", "ai-agent", "skills"], - "author": "", - "license": "MulanPSL-2.0" - } - PKGEOF - + export VERSION + rm -rf npm-pkg + mkdir -p npm-pkg + cp -R npm/. npm-pkg/ + cp README.md npm-pkg/README.md + rm -rf npm-pkg/skills + cp -R skills npm-pkg/skills + + node <<'NODE' + const fs = require('fs'); + const pkgPath = 'npm-pkg/package.json'; + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.version = process.env.VERSION; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + NODE + + chmod +x npm-pkg/bin/cli.js + chmod +x npm-pkg/bin/install-skills.js + cd npm-pkg npm publish --access public env: diff --git a/README.md b/README.md index 2ef60c1..cb2663d 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,16 @@ gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment v Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before. +### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary? + +Reinstall first: + +```bash +npm install -g @gitlink-ai/cli +``` + +If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli__windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`. + ### Q: Where are credentials stored on Windows? gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`). diff --git a/README.zh-CN.md b/README.zh-CN.md index ba4be15..4531992 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -386,6 +386,16 @@ gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。 +### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办? + +先尝试重新安装: + +```bash +npm install -g @gitlink-ai/cli +``` + +如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli__windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。 + ### Q: Windows 上凭证存储在哪里? gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。 diff --git a/npm/bin/cli.js b/npm/bin/cli.js index 474d69d..800301a 100755 --- a/npm/bin/cli.js +++ b/npm/bin/cli.js @@ -2,21 +2,78 @@ "use strict"; +const fs = require("fs"); const path = require("path"); const { execFileSync } = require("child_process"); -const ext = process.platform === "win32" ? ".exe" : ""; -const binaryPath = path.join(__dirname, "gitlink-cli" + ext); +const BINARY_NAME = "gitlink-cli"; -try { - execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" }); -} catch (err) { - if (err.status !== undefined) { - process.exit(err.status); - } - console.error(`Failed to run gitlink-cli: ${err.message}`); - console.error( - "Binary may not be installed. Try reinstalling: npm install -g @gitlink-ai/cli" - ); - process.exit(1); +function getBinaryName(platform = process.platform) { + return platform === "win32" ? `${BINARY_NAME}.exe` : BINARY_NAME; } + +function getBinaryPath(platform = process.platform, baseDir = __dirname) { + return path.join(baseDir, getBinaryName(platform)); +} + +function formatMissingBinaryError( + binaryPath, + platform = process.platform, + arch = process.arch +) { + return [ + `Error: ${BINARY_NAME} binary not found at ${binaryPath}`, + `Platform: ${platform}/${arch}`, + "", + "The npm package was installed, but the native binary is missing.", + "This usually means the release asset for your platform is unavailable or postinstall failed.", + "", + "Try reinstalling:", + " npm install -g @gitlink-ai/cli", + "", + "If the problem persists, check the GitLink CLI release assets:", + " https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases", + ].join("\n"); +} + +function run(args = process.argv.slice(2), options = {}) { + const platform = options.platform || process.platform; + const arch = options.arch || process.arch; + const binaryPath = options.binaryPath || getBinaryPath(platform); + const execFile = options.execFileSync || execFileSync; + const stderr = options.stderr || process.stderr; + const exit = options.exit || process.exit; + + function failMissingBinary() { + stderr.write(`${formatMissingBinaryError(binaryPath, platform, arch)}\n`); + return exit(1); + } + + if (!fs.existsSync(binaryPath)) { + return failMissingBinary(); + } + + try { + execFile(binaryPath, args, { stdio: "inherit" }); + } catch (err) { + if (err.code === "ENOENT") { + return failMissingBinary(); + } + if (err.status !== undefined) { + return exit(err.status); + } + stderr.write(`Failed to run ${BINARY_NAME}: ${err.message}\n`); + return exit(1); + } +} + +if (require.main === module) { + run(); +} + +module.exports = { + getBinaryName, + getBinaryPath, + formatMissingBinaryError, + run, +}; diff --git a/npm/bin/install-skills.js b/npm/bin/install-skills.js old mode 100644 new mode 100755 diff --git a/npm/package.json b/npm/package.json index ceb1f48..d4ab32a 100644 --- a/npm/package.json +++ b/npm/package.json @@ -7,7 +7,8 @@ "gitlink-cli-install-skills": "bin/install-skills.js" }, "scripts": { - "postinstall": "node scripts/install.js" + "postinstall": "node scripts/install.js", + "test": "node test/install.test.js && node test/cli.test.js" }, "keywords": [ "gitlink", diff --git a/npm/scripts/install.js b/npm/scripts/install.js index ef82bdc..cd9e599 100644 --- a/npm/scripts/install.js +++ b/npm/scripts/install.js @@ -20,10 +20,7 @@ const RELEASE_BASE = "https://www.gitlink.org.cn"; const REPO_OWNER = "Gitlink"; const REPO_NAME = "gitlink-cli"; -function getPlatformInfo() { - const platform = os.platform(); - const arch = os.arch(); - +function getPlatformInfo(platform = os.platform(), arch = os.arch()) { const platformMap = { darwin: "darwin", linux: "linux", @@ -123,6 +120,7 @@ function fetch(url, options = {}) { async function findReleaseAsset(platform, arch) { const archiveName = getArchiveName(platform, arch); + const tagName = `v${VERSION}`; // Try fetching release info from GitLink API const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`; @@ -133,7 +131,6 @@ async function findReleaseAsset(platform, arch) { // Find the release matching our version let release = null; - const tagName = `v${VERSION}`; if (Array.isArray(releases)) { release = releases.find( @@ -234,9 +231,15 @@ async function downloadAndExtract(url, destDir, platform) { } async function main() { + let platformInfo = null; + let archiveName = null; + try { - const { platform, arch } = getPlatformInfo(); + platformInfo = getPlatformInfo(); + const { platform, arch } = platformInfo; + archiveName = getArchiveName(platform, arch); console.log(`Platform: ${platform}-${arch}`); + console.log(`Expected release asset: ${archiveName}`); const binDir = path.join(__dirname, "..", "bin"); if (!fs.existsSync(binDir)) { @@ -264,6 +267,12 @@ async function main() { await downloadAndExtract(downloadUrl, binDir, platform); } catch (err) { console.error(`\nFailed to install ${BINARY_NAME}: ${err.message}`); + if (platformInfo) { + console.error(`Platform: ${platformInfo.platform}/${platformInfo.arch}`); + } + if (archiveName) { + console.error(`Expected release asset: ${archiveName}`); + } console.error( `\nYou can install manually:\n` + ` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` + @@ -274,4 +283,13 @@ async function main() { } } -main(); +if (require.main === module) { + main(); +} + +module.exports = { + getPlatformInfo, + getBinaryName, + getArchiveName, + findReleaseAsset, +}; diff --git a/npm/test/cli.test.js b/npm/test/cli.test.js new file mode 100644 index 0000000..338cef4 --- /dev/null +++ b/npm/test/cli.test.js @@ -0,0 +1,52 @@ +"use strict"; + +const assert = require("assert"); +const path = require("path"); +const os = require("os"); +const cli = require("../bin/cli.js"); + +assert.equal(cli.getBinaryName("win32"), "gitlink-cli.exe"); +assert.equal(cli.getBinaryName("linux"), "gitlink-cli"); +assert.equal(cli.getBinaryName("darwin"), "gitlink-cli"); + +assert.equal( + cli.getBinaryPath("win32", "C:\\tmp\\gitlink"), + path.join("C:\\tmp\\gitlink", "gitlink-cli.exe") +); + +const message = cli.formatMissingBinaryError( + "C:\\tmp\\gitlink-cli.exe", + "win32", + "x64" +); +assert.match(message, /binary not found/); +assert.match(message, /Platform: win32\/x64/); +assert.match(message, /npm install -g @gitlink-ai\/cli/); + +let exitCode = null; +const stderr = { + output: "", + write(text) { + this.output += text; + }, +}; + +cli.run(["version"], { + binaryPath: path.join(os.tmpdir(), "gitlink-cli-test-missing-binary"), + platform: "win32", + arch: "x64", + stderr, + exit(code) { + exitCode = code; + return code; + }, + execFileSync() { + throw new Error("execFileSync should not be called for a missing binary"); + }, +}); + +assert.equal(exitCode, 1); +assert.match(stderr.output, /gitlink-cli binary not found/); +assert.match(stderr.output, /Platform: win32\/x64/); + +console.log("cli wrapper tests passed"); diff --git a/npm/test/install.test.js b/npm/test/install.test.js new file mode 100644 index 0000000..8df33c8 --- /dev/null +++ b/npm/test/install.test.js @@ -0,0 +1,53 @@ +"use strict"; + +const assert = require("assert"); +const install = require("../scripts/install.js"); +const pkg = require("../package.json"); + +assert.deepStrictEqual(install.getPlatformInfo("win32", "x64"), { + platform: "windows", + arch: "amd64", + isWindows: true, +}); + +assert.deepStrictEqual(install.getPlatformInfo("win32", "arm64"), { + platform: "windows", + arch: "arm64", + isWindows: true, +}); + +assert.deepStrictEqual(install.getPlatformInfo("darwin", "arm64"), { + platform: "darwin", + arch: "arm64", + isWindows: false, +}); + +assert.deepStrictEqual(install.getPlatformInfo("linux", "x64"), { + platform: "linux", + arch: "amd64", + isWindows: false, +}); + +assert.equal(install.getBinaryName("windows"), "gitlink-cli.exe"); +assert.equal(install.getBinaryName("linux"), "gitlink-cli"); +assert.equal(install.getBinaryName("darwin"), "gitlink-cli"); + +assert.equal( + install.getArchiveName("windows", "amd64"), + `gitlink-cli_${pkg.version}_windows_amd64.zip` +); +assert.equal( + install.getArchiveName("windows", "arm64"), + `gitlink-cli_${pkg.version}_windows_arm64.zip` +); +assert.equal( + install.getArchiveName("linux", "amd64"), + `gitlink-cli_${pkg.version}_linux_amd64.tar.gz` +); + +assert.throws( + () => install.getPlatformInfo("freebsd", "x64"), + /Unsupported platform/ +); + +console.log("install helper tests passed"); diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index d2ea8f2..dc52630 100755 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -74,13 +74,14 @@ rm -rf "$NPM_DIR/skills" cp -r "$PROJECT_DIR/skills" "$NPM_DIR/skills" # Ensure bin dir exists and wrapper is executable -chmod +x "$NPM_DIR/bin/gitlink-cli" +chmod +x "$NPM_DIR/bin/cli.js" +chmod +x "$NPM_DIR/bin/install-skills.js" echo "" echo "=== Done ===" echo "" echo "Next steps:" -echo " 1. Upload dist/*.tar.gz to GitLink Release v${VERSION}" +echo " 1. Upload dist/*.tar.gz and dist/*.zip to GitLink Release v${VERSION}" echo " URL: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases" echo "" echo " 2. Publish npm package:"