qmai-cli-public 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/npm/install.js +128 -32
  2. package/package.json +1 -1
package/npm/install.js CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  const fs = require("node:fs");
4
4
  const fsp = require("node:fs/promises");
5
+ const http = require("node:http");
5
6
  const https = require("node:https");
6
7
  const path = require("node:path");
7
8
  const { spawnSync } = require("node:child_process");
9
+ const { URL } = require("node:url");
8
10
 
9
11
  const packageRoot = path.resolve(__dirname, "..");
10
12
  const distDir = path.join(packageRoot, "npm", "dist");
@@ -21,6 +23,10 @@ const archMap = {
21
23
  arm64: "arm64",
22
24
  };
23
25
 
26
+ const DOWNLOAD_TIMEOUT_MS = parseInt(process.env.QMAI_CLI_DOWNLOAD_TIMEOUT_MS || "30000", 10);
27
+ const DOWNLOAD_RETRIES = parseInt(process.env.QMAI_CLI_DOWNLOAD_RETRIES || "3", 10);
28
+ const MAX_REDIRECTS = parseInt(process.env.QMAI_CLI_DOWNLOAD_MAX_REDIRECTS || "5", 10);
29
+
24
30
  function fail(message) {
25
31
  console.error(message);
26
32
  process.exit(1);
@@ -59,22 +65,70 @@ function getAssetInfo() {
59
65
  return { version, platform, arch, baseName, archiveName, binaryName };
60
66
  }
61
67
 
62
- function getDownloadUrl(archiveName) {
63
- const customUrl = process.env.QMAI_CLI_DOWNLOAD_URL;
64
- if (customUrl) {
65
- return customUrl;
68
+ function parseList(value) {
69
+ return (value || "")
70
+ .split(",")
71
+ .map((item) => item.trim())
72
+ .filter(Boolean);
73
+ }
74
+
75
+ function getDownloadCandidates(archiveName) {
76
+ const candidates = [];
77
+ const seen = new Set();
78
+
79
+ const add = (url) => {
80
+ if (!url || seen.has(url)) {
81
+ return;
82
+ }
83
+ seen.add(url);
84
+ candidates.push(url);
85
+ };
86
+
87
+ add(process.env.QMAI_CLI_DOWNLOAD_URL);
88
+ parseList(process.env.QMAI_CLI_FALLBACK_DOWNLOAD_URLS).forEach(add);
89
+
90
+ const baseUrls = [
91
+ process.env.QMAI_CLI_RELEASE_BASE_URL || "https://github.com/feixiao629/qmai-cli-public/releases/download",
92
+ ...parseList(process.env.QMAI_CLI_FALLBACK_RELEASE_BASE_URLS),
93
+ ];
94
+ for (const baseUrl of baseUrls) {
95
+ const normalized = baseUrl.replace(/\/+$/, "");
96
+ add(`${normalized}/v${packageJson.version}/${archiveName}`);
97
+ }
98
+
99
+ return candidates;
100
+ }
101
+
102
+ function getHttpClient(url) {
103
+ const protocol = new URL(url).protocol;
104
+ if (protocol === "https:") {
105
+ return https;
106
+ }
107
+ if (protocol === "http:") {
108
+ return http;
66
109
  }
110
+ throw new Error(`unsupported protocol: ${protocol}`);
111
+ }
67
112
 
68
- const baseUrl =
69
- process.env.QMAI_CLI_RELEASE_BASE_URL ||
70
- "https://github.com/feixiao629/qmai-cli-public/releases/download";
71
- return `${baseUrl}/v${packageJson.version}/${archiveName}`;
113
+ async function removeIfExists(filePath) {
114
+ await fsp.rm(filePath, { force: true });
72
115
  }
73
116
 
74
- async function downloadFile(url, outputPath) {
117
+ async function downloadFileOnce(url, outputPath, redirectCount = 0) {
118
+ if (redirectCount > MAX_REDIRECTS) {
119
+ throw new Error(`redirect limit exceeded (${MAX_REDIRECTS})`);
120
+ }
121
+
75
122
  await new Promise((resolve, reject) => {
76
123
  const file = fs.createWriteStream(outputPath);
77
- const request = https.get(
124
+ const client = getHttpClient(url);
125
+
126
+ const cleanup = (callback) => {
127
+ file.destroy();
128
+ removeIfExists(outputPath).then(() => callback()).catch(callback);
129
+ };
130
+
131
+ const request = client.get(
78
132
  url,
79
133
  {
80
134
  headers: {
@@ -83,42 +137,85 @@ async function downloadFile(url, outputPath) {
83
137
  },
84
138
  },
85
139
  (response) => {
86
- if (
87
- response.statusCode &&
88
- response.statusCode >= 300 &&
89
- response.statusCode < 400 &&
90
- response.headers.location
91
- ) {
92
- file.close();
93
- fs.unlinkSync(outputPath);
94
- downloadFile(response.headers.location, outputPath).then(resolve).catch(reject);
140
+ const { statusCode = 0, statusMessage = "", headers } = response;
141
+
142
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
143
+ const nextUrl = new URL(headers.location, url).toString();
144
+ response.resume();
145
+ file.close(() => {
146
+ removeIfExists(outputPath)
147
+ .then(() => downloadFileOnce(nextUrl, outputPath, redirectCount + 1))
148
+ .then(resolve)
149
+ .catch(reject);
150
+ });
95
151
  return;
96
152
  }
97
153
 
98
- if (response.statusCode !== 200) {
99
- file.close();
100
- fs.unlinkSync(outputPath);
101
- reject(
102
- new Error(`download failed: ${response.statusCode} ${response.statusMessage || ""}`.trim())
154
+ if (statusCode !== 200) {
155
+ response.resume();
156
+ cleanup(() =>
157
+ reject(new Error(`download failed: ${statusCode} ${statusMessage}`.trim()))
103
158
  );
104
159
  return;
105
160
  }
106
161
 
107
162
  response.pipe(file);
108
163
  file.on("finish", () => file.close(resolve));
164
+ response.on("error", (error) => {
165
+ cleanup(() => reject(error));
166
+ });
109
167
  }
110
168
  );
111
169
 
170
+ request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
171
+ request.destroy(new Error(`download timeout after ${DOWNLOAD_TIMEOUT_MS}ms`));
172
+ });
173
+
112
174
  request.on("error", (error) => {
113
- file.close();
114
- if (fs.existsSync(outputPath)) {
115
- fs.unlinkSync(outputPath);
116
- }
117
- reject(error);
175
+ cleanup(() => reject(error));
176
+ });
177
+
178
+ file.on("error", (error) => {
179
+ request.destroy(error);
118
180
  });
119
181
  });
120
182
  }
121
183
 
184
+ async function downloadFile(urls, outputPath) {
185
+ const attempts = [];
186
+
187
+ for (const url of urls) {
188
+ for (let attempt = 1; attempt <= DOWNLOAD_RETRIES; attempt += 1) {
189
+ try {
190
+ console.log(`下载 qmai 二进制(${attempt}/${DOWNLOAD_RETRIES}):${url}`);
191
+ await downloadFileOnce(url, outputPath);
192
+ return;
193
+ } catch (error) {
194
+ attempts.push(`${url} [${attempt}/${DOWNLOAD_RETRIES}]: ${error.message}`);
195
+ console.warn(`下载失败:${error.message}`);
196
+ await removeIfExists(outputPath);
197
+ }
198
+ }
199
+ }
200
+
201
+ throw new Error(
202
+ [
203
+ "无法下载 qmai 二进制。",
204
+ `已尝试 ${urls.length} 个下载源,每个最多重试 ${DOWNLOAD_RETRIES} 次。`,
205
+ `超时设置:${DOWNLOAD_TIMEOUT_MS}ms。`,
206
+ "可设置以下环境变量重试:",
207
+ "- QMAI_CLI_DOWNLOAD_TIMEOUT_MS=60000",
208
+ "- QMAI_CLI_DOWNLOAD_RETRIES=5",
209
+ "- QMAI_CLI_RELEASE_BASE_URL=https://<your-mirror>/releases/download",
210
+ "- QMAI_CLI_FALLBACK_RELEASE_BASE_URLS=https://mirror-a/releases/download,https://mirror-b/releases/download",
211
+ "- QMAI_CLI_DOWNLOAD_URL=https://.../qmai_<version>_<os>_<arch>.tar.gz",
212
+ "- QMAI_CLI_LOCAL_ASSET_DIR=/path/to/pre-downloaded-assets",
213
+ "失败明细:",
214
+ ...attempts.map((item) => ` - ${item}`),
215
+ ].join("\n")
216
+ );
217
+ }
218
+
122
219
  async function extractArchive(archivePath, targetDir, platform) {
123
220
  if (platform === "windows") {
124
221
  runCommand("powershell.exe", [
@@ -153,9 +250,8 @@ async function main() {
153
250
  if (localAssetDir) {
154
251
  await copyLocalAsset(localAssetDir, archiveName, archivePath);
155
252
  } else {
156
- const url = getDownloadUrl(archiveName);
157
- console.log(`下载 qmai 二进制:${url}`);
158
- await downloadFile(url, archivePath);
253
+ const urls = getDownloadCandidates(archiveName);
254
+ await downloadFile(urls, archivePath);
159
255
  }
160
256
 
161
257
  await extractArchive(archivePath, distDir, platform);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qmai-cli-public",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "qmai-cli npm installer package",
5
5
  "license": "MIT",
6
6
  "repository": {