Compare commits

...

1 Commits

Author SHA1 Message Date
wbtiger 29d202c6da fix(npm): Windows 安装后二进制缺失,所有命令静默退出
问题:Windows 用户 npm install 后 gitlink-cli 无任何输出。

根因:
1. release.yml 不构建 Windows 二进制(只有 darwin/linux)
2. workflow 内联了旧版 install.js(从 github.com/ccfos 下载、只处理 .tar.gz)
3. cli.js 不检查二进制是否存在,execFileSync 失败时静默退出

修复:
- release.yml: 加入 windows amd64/arm64 构建,输出 .zip;
  删除内联 fallback 脚本,始终使用 npm/ 目录下的文件
- npm/bin/cli.js: 加 fs.existsSync 检查,缺失时输出明确错误和修复指引
- npm/scripts/install.js: 加 GitHub fallback 下载源
- scripts/install.js: 加 Windows 支持(.exe 后缀、.zip 解压、GitHub fallback)

Closes #16
2026-05-19 16:21:05 +08:00
4 changed files with 109 additions and 151 deletions

View File

@ -27,141 +27,45 @@ jobs:
run: | run: |
mkdir -p dist mkdir -p dist
VERSION=${GITHUB_REF#refs/tags/v} VERSION=${GITHUB_REF#refs/tags/v}
for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64"; do for pair in "darwin amd64" "darwin arm64" "linux amd64" "linux arm64" "windows amd64" "windows arm64"; do
GOOS=$(echo $pair | cut -d' ' -f1) GOOS=$(echo $pair | cut -d' ' -f1)
GOARCH=$(echo $pair | cut -d' ' -f2) GOARCH=$(echo $pair | cut -d' ' -f2)
echo "Building ${GOOS}-${GOARCH}..." 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 .
EXT=""
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
GOOS=$GOOS GOARCH=$GOARCH go build -ldflags "-s -w -X 'github.com/gitlink-org/gitlink-cli/cmd.Version=${VERSION}'" -o "dist/gitlink-cli${EXT}" .
cd dist cd dist
tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" gitlink-cli if [ "$GOOS" = "windows" ]; then
rm gitlink-cli zip "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.zip" "gitlink-cli${EXT}"
else
tar -czf "gitlink-cli_${VERSION}_${GOOS}_${GOARCH}.tar.gz" "gitlink-cli${EXT}"
fi
rm "gitlink-cli${EXT}"
cd .. cd ..
done done
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: dist/*.tar.gz files: |
dist/*.tar.gz
dist/*.zip
generate_release_notes: true generate_release_notes: true
- name: Build npm package - name: Publish npm package
run: | run: |
VERSION=${GITHUB_REF#refs/tags/v} VERSION=${GITHUB_REF#refs/tags/v}
mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills mkdir -p npm-pkg/bin npm-pkg/scripts npm-pkg/skills
# Copy skills
cp -r skills/* npm-pkg/skills/ cp -r skills/* npm-pkg/skills/
cp npm/bin/cli.js npm-pkg/bin/
# Copy bin wrappers cp npm/bin/install-skills.js npm-pkg/bin/
cp bin/cli.js npm-pkg/bin/ 2>/dev/null || cat > npm-pkg/bin/cli.js << 'NODEEOF' cp npm/scripts/install.js npm-pkg/scripts/
#!/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/ cp README.md npm-pkg/
# Create package.json
cat > npm-pkg/package.json << PKGEOF cat > npm-pkg/package.json << PKGEOF
{ {
"name": "@gitlink-ai/cli", "name": "@gitlink-ai/cli",
@ -184,14 +88,14 @@ jobs:
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/ccfos/gitlink-cli.git" "url": "https://www.gitlink.org.cn/Gitlink/gitlink-cli.git"
}, },
"keywords": ["gitlink", "cli", "ai-agent", "skills"], "keywords": ["gitlink", "cli", "ai-agent", "skills"],
"author": "", "author": "GitLink <support@gitlink.org.cn>",
"license": "MulanPSL-2.0" "license": "MulanPSL-2.0"
} }
PKGEOF PKGEOF
cd npm-pkg cd npm-pkg
npm publish --access public npm publish --access public
env: env:

View File

@ -2,12 +2,27 @@
"use strict"; "use strict";
const fs = require("fs");
const path = require("path"); const path = require("path");
const { execFileSync } = require("child_process"); const { execFileSync } = require("child_process");
const ext = process.platform === "win32" ? ".exe" : ""; const ext = process.platform === "win32" ? ".exe" : "";
const binaryPath = path.join(__dirname, "gitlink-cli" + ext); const binaryPath = path.join(__dirname, "gitlink-cli" + ext);
if (!fs.existsSync(binaryPath)) {
console.error(
`Error: gitlink-cli binary not found at ${binaryPath}\n\n` +
`The binary was not downloaded during installation.\n` +
`This usually happens when the postinstall script failed (e.g. network issues).\n\n` +
`To fix, try one of:\n` +
` 1. Reinstall: npm install -g @gitlink-ai/cli\n` +
` 2. Manual download from:\n` +
` https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases\n` +
` Then place the binary at: ${binaryPath}\n`
);
process.exit(1);
}
try { try {
execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" }); execFileSync(binaryPath, process.argv.slice(2), { stdio: "inherit" });
} catch (err) { } catch (err) {
@ -15,8 +30,5 @@ try {
process.exit(err.status); process.exit(err.status);
} }
console.error(`Failed to run gitlink-cli: ${err.message}`); 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); process.exit(1);
} }

View File

@ -13,13 +13,16 @@ const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version; const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli"; const BINARY_NAME = "gitlink-cli";
// GitLink release download base URL // GitLink release download (primary)
// Format: https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases
// Attachment download: https://www.gitlink.org.cn/api/attachments/{attachment_id}
const RELEASE_BASE = "https://www.gitlink.org.cn"; const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink"; const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli"; const REPO_NAME = "gitlink-cli";
// GitHub release download (fallback for users who cannot reach GitLink CDN)
const GITHUB_RELEASE_BASE = "https://github.com";
const GITHUB_REPO_OWNER = "ccfos";
const GITHUB_REPO_NAME = "gitlink-cli";
function getPlatformInfo() { function getPlatformInfo() {
const platform = os.platform(); const platform = os.platform();
const arch = os.arch(); const arch = os.arch();
@ -180,8 +183,9 @@ async function findReleaseAsset(platform, arch) {
console.log(`Warning: Could not fetch release info: ${e.message}`); console.log(`Warning: Could not fetch release info: ${e.message}`);
} }
// Fallback: try direct download URL pattern // Fallback: download from GitHub releases
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`; const tagName = `v${VERSION}`;
return `${GITHUB_RELEASE_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/download/${tagName}/${archiveName}`;
} }
async function downloadAndExtract(url, destDir, platform) { async function downloadAndExtract(url, destDir, platform) {

View File

@ -17,6 +17,10 @@ const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink"; const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli"; const REPO_NAME = "gitlink-cli";
const GITHUB_RELEASE_BASE = "https://github.com";
const GITHUB_REPO_OWNER = "ccfos";
const GITHUB_REPO_NAME = "gitlink-cli";
function getPlatformInfo() { function getPlatformInfo() {
const platform = os.platform(); const platform = os.platform();
const arch = os.arch(); const arch = os.arch();
@ -30,6 +34,18 @@ function getPlatformInfo() {
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" }; return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
} }
function getBinaryName(isWindows) {
return isWindows ? BINARY_NAME + ".exe" : BINARY_NAME;
}
function getArchiveExt(isWindows) {
return isWindows ? ".zip" : ".tar.gz";
}
function getArchiveName(platform, arch, isWindows) {
return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${getArchiveExt(isWindows)}`;
}
function fetch(url, options = {}) { function fetch(url, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5; const maxRedirects = options.maxRedirects || 5;
@ -65,14 +81,14 @@ function fetch(url, options = {}) {
} }
}); });
req.on("error", reject); req.on("error", reject);
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); }); req.setTimeout(60000, () => { req.destroy(); reject(new Error("Timeout")); });
} }
doRequest(url); doRequest(url);
}); });
} }
async function findReleaseAsset(platform, arch) { async function findReleaseAsset(platform, arch, isWindows) {
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`; const archiveName = getArchiveName(platform, arch, isWindows);
const tagName = `v${VERSION}`; const tagName = `v${VERSION}`;
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`; const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
@ -85,7 +101,7 @@ async function findReleaseAsset(platform, arch) {
if (release && release.attachments) { if (release && release.attachments) {
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName); let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
if (!asset) { if (!asset) {
const pattern = `_${platform}_${arch}.tar.gz`; const pattern = `_${platform}_${arch}${getArchiveExt(isWindows)}`;
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern)); asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
} }
if (asset) { if (asset) {
@ -94,36 +110,53 @@ async function findReleaseAsset(platform, arch) {
return url; return url;
} }
} }
} catch (e) {} } catch (e) {
console.log(`Warning: GitLink API failed: ${e.message}, trying GitHub...`);
}
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`; return `${GITHUB_RELEASE_BASE}/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/releases/download/${tagName}/${archiveName}`;
} }
async function downloadAndExtract(url, destDir, platform) { async function downloadAndExtract(url, destDir, isWindows) {
console.log(`Downloading ${BINARY_NAME} from ${url}...`);
const data = await fetch(url); const data = await fetch(url);
const archivePath = path.join(destDir, "download.tar.gz"); console.log(`Downloaded ${(data.length / 1024 / 1024).toFixed(1)} MB`);
const archivePath = path.join(destDir, "download" + getArchiveExt(isWindows));
fs.writeFileSync(archivePath, data); fs.writeFileSync(archivePath, data);
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
if (isWindows) {
execSync(
`powershell -NoProfile -Command "Expand-Archive -Force -Path '${archivePath}' -DestinationPath '${destDir}'"`,
{ stdio: "pipe" }
);
} else {
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
}
fs.unlinkSync(archivePath); fs.unlinkSync(archivePath);
const binaryPath = path.join(destDir, BINARY_NAME); const binaryName = getBinaryName(isWindows);
const binaryPath = path.join(destDir, binaryName);
if (!fs.existsSync(binaryPath)) { if (!fs.existsSync(binaryPath)) {
const files = fs.readdirSync(destDir); const files = fs.readdirSync(destDir);
for (const file of files) { for (const file of files) {
const subPath = path.join(destDir, file, BINARY_NAME); const subPath = path.join(destDir, file, binaryName);
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; } if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
} }
} }
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction"); if (!fs.existsSync(binaryPath)) throw new Error(`Binary "${binaryName}" not found after extraction`);
fs.chmodSync(binaryPath, 0o755); if (!isWindows) fs.chmodSync(binaryPath, 0o755);
} }
async function main() { async function main() {
const { platform, arch } = getPlatformInfo(); const { platform, arch, isWindows } = getPlatformInfo();
console.log(`Platform: ${platform}-${arch}`);
const binDir = path.join(__dirname, "..", "bin"); const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); } if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
const binaryPath = path.join(binDir, BINARY_NAME); const binaryPath = path.join(binDir, getBinaryName(isWindows));
if (fs.existsSync(binaryPath)) { if (fs.existsSync(binaryPath)) {
try { try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 }); const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
@ -131,19 +164,24 @@ async function main() {
console.log(`${BINARY_NAME} v${VERSION} already installed.`); console.log(`${BINARY_NAME} v${VERSION} already installed.`);
return; return;
} }
console.log(`Version mismatch (got: ${output.trim()}, want: ${VERSION}), updating...`);
} catch (e) {} } catch (e) {}
fs.unlinkSync(binaryPath); fs.unlinkSync(binaryPath);
} }
try { try {
const downloadUrl = await findReleaseAsset(platform, arch); const downloadUrl = await findReleaseAsset(platform, arch, isWindows);
await downloadAndExtract(downloadUrl, binDir, platform); await downloadAndExtract(downloadUrl, binDir, isWindows);
console.log(`${BINARY_NAME} v${VERSION} installed.`); console.log(`${BINARY_NAME} v${VERSION} installed successfully.`);
} catch (err) { } catch (err) {
// Don't fail npm install — binary can be installed later console.error(`\n Failed to install ${BINARY_NAME}: ${err.message}`);
console.warn(`${BINARY_NAME} binary download failed: ${err.message}`); console.error(
console.warn(` Skills are installed. You can install the binary manually later:`); `\n You can install manually:\n` +
console.warn(` npm run postinstall`); ` 1. Download from https://www.gitlink.org.cn/${REPO_OWNER}/${REPO_NAME}/releases\n` +
` 2. Extract and place the binary in: ${binDir}\n` +
` 3. Or retry: npm run postinstall\n`
);
process.exit(1);
} }
} }