@agile-team/robot-cli 3.0.0 → 3.0.2

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 (3) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.js +107 -41
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [3.0.2] - 2026-03-29
9
+
10
+ ### Fixed
11
+
12
+ - **根治 ZIP 下载内容校验问题** — 新增两层防护:
13
+ 1. `Content-Type` 检测: 若服务器返回 `text/html`(登录页/CAPTCHA/跳转页)立即报错,`tryDownload` 自动切换到下一个源(codeload CDN → GitHub API → github.com)
14
+ 2. ZIP 魔术字节校验 (`PK 50 4B`): 写入磁盘前检查 buffer 头部,若不是合法 ZIP 抛出含内容预览的可读错误,而非让 `extract-zip` 报 `end of central directory record signature not found`
15
+
16
+ ## [3.0.1] - 2026-03-28
17
+
18
+ ### Fixed
19
+
20
+ - **下载策略全面重写** — 参考 giget (unjs/giget, 306k+ 项目使用) 的下载方式:
21
+ - Gitee 备用源提升为首选 (国内用户最快)
22
+ - 使用 `codeload.github.com` 直连 CDN 替代 `github.com/archive/` (跳过 302 重定向)
23
+ - 使用 `api.github.com` REST API 作为二级备选 (与 giget 一致)
24
+ - `github.com` 原始链接降为最后兜底
25
+ - **移除不稳定第三方镜像** — 移除 `hub.gitmirror.com` 和 `ghproxy.net` (不可控第三方代理)
26
+ - **超时优化** — 快速源 (Gitee/codeload) 30s, 慢速源 (API/github.com) 60s, 不再需要 120s
27
+ - **重试缩减** — 每源 2 次重试 (原 3 次), 退避时间 1s/2s (原 2s/4s), 快速失败切换下一源
28
+ - **错误信息增强** — 失败时列出每个源的具体错误原因
29
+
8
30
  ## [3.0.0] - 2026-03-28
9
31
 
10
32
  ### ⚠️ BREAKING CHANGES
package/dist/index.js CHANGED
@@ -266,12 +266,29 @@ var START_COMMAND_MAP = {
266
266
  // src/download.ts
267
267
  var CACHE_DIR = path.join(os.homedir(), ".robot-cli", CACHE_DIR_NAME);
268
268
  var CACHE_INDEX_PATH = path.join(CACHE_DIR, "index.json");
269
+ function parseGitHubRepo(repoUrl) {
270
+ try {
271
+ const url = new URL(repoUrl);
272
+ if (url.hostname !== "github.com") return null;
273
+ const parts = url.pathname.replace(/^\/|\/$/g, "").split("/");
274
+ if (parts.length >= 2) return { owner: parts[0], repo: parts[1] };
275
+ return null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
269
280
  function buildDownloadUrl(repoUrl, branch = "main") {
270
281
  try {
271
282
  const url = new URL(repoUrl);
272
283
  const host = url.hostname;
273
284
  const cleanUrl = repoUrl.replace(/\/+$/, "");
274
- if (host === "github.com") return `${cleanUrl}/archive/refs/heads/${branch}.zip`;
285
+ if (host === "github.com") {
286
+ const gh = parseGitHubRepo(cleanUrl);
287
+ if (gh) return `https://codeload.github.com/${gh.owner}/${gh.repo}/zip/refs/heads/${branch}`;
288
+ return `${cleanUrl}/archive/refs/heads/${branch}.zip`;
289
+ }
290
+ if (host === "codeload.github.com") return `${cleanUrl}/zip/refs/heads/${branch}`;
291
+ if (host === "api.github.com") return cleanUrl;
275
292
  if (host === "gitee.com")
276
293
  return `${cleanUrl}/repository/archive/${branch}.zip`;
277
294
  if (host === "gitlab.com") {
@@ -343,76 +360,123 @@ async function getCacheStats() {
343
360
  async function clearCache() {
344
361
  await fs.remove(CACHE_DIR);
345
362
  }
346
- var TIMEOUT_PRIMARY = 12e4;
347
- var TIMEOUT_MIRROR = 6e4;
348
- var MAX_RETRIES = 3;
349
- function getGitHubMirrors(repoUrl, giteeUrl) {
350
- const mirrors = [
351
- repoUrl,
352
- // 1. 原始 GitHub
353
- repoUrl.replace("github.com", "hub.gitmirror.com"),
354
- // 2. gitmirror
355
- `https://ghproxy.net/${repoUrl}`
356
- // 3. ghproxy.net
357
- ];
358
- if (giteeUrl) mirrors.push(giteeUrl);
359
- return mirrors;
363
+ var TIMEOUT_FAST = 3e4;
364
+ var TIMEOUT_SLOW = 6e4;
365
+ var MAX_RETRIES = 2;
366
+ function buildDownloadSources(repoUrl, branch, giteeUrl) {
367
+ const sources = [];
368
+ const gh = parseGitHubRepo(repoUrl);
369
+ if (giteeUrl) {
370
+ sources.push({
371
+ url: giteeUrl,
372
+ name: "gitee.com",
373
+ timeout: TIMEOUT_FAST
374
+ });
375
+ }
376
+ if (gh) {
377
+ sources.push({
378
+ url: `https://codeload.github.com/${gh.owner}/${gh.repo}/zip/refs/heads/${branch}`,
379
+ name: "codeload.github.com",
380
+ timeout: TIMEOUT_FAST,
381
+ isDirect: true
382
+ });
383
+ sources.push({
384
+ url: `https://api.github.com/repos/${gh.owner}/${gh.repo}/zipball/${branch}`,
385
+ name: "api.github.com",
386
+ timeout: TIMEOUT_SLOW,
387
+ isDirect: true,
388
+ headers: {
389
+ Accept: "application/vnd.github+json",
390
+ "X-GitHub-Api-Version": "2022-11-28"
391
+ }
392
+ });
393
+ sources.push({
394
+ url: repoUrl,
395
+ name: "github.com",
396
+ timeout: TIMEOUT_SLOW
397
+ });
398
+ } else {
399
+ sources.push({
400
+ url: repoUrl,
401
+ name: new URL(repoUrl).hostname,
402
+ timeout: TIMEOUT_SLOW
403
+ });
404
+ }
405
+ return sources;
360
406
  }
361
- async function fetchWithRetry(downloadUrl, timeout, retries) {
407
+ async function fetchWithRetry(downloadUrl, timeout, retries, extraHeaders) {
362
408
  let lastError;
363
409
  for (let attempt = 1; attempt <= retries; attempt++) {
364
410
  try {
365
411
  const response = await fetch(downloadUrl, {
366
412
  signal: AbortSignal.timeout(timeout),
367
- headers: { "User-Agent": "Robot-CLI" }
413
+ redirect: "follow",
414
+ headers: {
415
+ "User-Agent": "Robot-CLI/3.0",
416
+ ...extraHeaders
417
+ }
368
418
  });
369
419
  if (!response.ok) {
370
420
  if (response.status === 404) throw new Error(`\u4ED3\u5E93\u4E0D\u5B58\u5728 (404)`);
371
421
  throw new Error(`HTTP ${response.status}`);
372
422
  }
423
+ const ct = response.headers.get("content-type") ?? "";
424
+ if (ct.includes("text/html")) {
425
+ throw new Error(`\u6536\u5230 HTML \u54CD\u5E94\u800C\u975E ZIP \u6587\u4EF6\uFF08\u8BE5\u6E90\u53EF\u80FD\u9700\u8981\u8BA4\u8BC1\uFF09`);
426
+ }
373
427
  return response;
374
428
  } catch (error) {
375
429
  lastError = error;
376
430
  if (lastError.message.includes("404")) throw lastError;
377
431
  if (attempt < retries) {
378
- await new Promise((r) => setTimeout(r, 2e3 * attempt));
432
+ await new Promise((r) => setTimeout(r, 1e3 * attempt));
379
433
  }
380
434
  }
381
435
  }
382
436
  throw lastError;
383
437
  }
384
438
  async function tryDownload(repoUrl, branch = "main", spinner, giteeUrl) {
385
- const url = new URL(repoUrl);
386
- const host = url.hostname;
387
- const mirrors = host === "github.com" ? getGitHubMirrors(repoUrl, giteeUrl) : [repoUrl];
388
- for (let i = 0; i < mirrors.length; i++) {
389
- const current = mirrors[i];
390
- const isOriginal = i === 0;
391
- let sourceName;
392
- try {
393
- sourceName = isOriginal ? host : new URL(current.replace(/^(https?:\/\/[^/]+)\/.*/, "$1")).hostname;
394
- } catch {
395
- sourceName = `\u955C\u50CF ${i}`;
396
- }
439
+ const sources = buildDownloadSources(repoUrl, branch, giteeUrl);
440
+ const errors = [];
441
+ for (let i = 0; i < sources.length; i++) {
442
+ const source = sources[i];
397
443
  try {
398
- if (spinner) spinner.text = `\u8FDE\u63A5 ${sourceName} ...`;
399
- const downloadUrl = current.endsWith(".zip") ? current : buildDownloadUrl(current, branch);
400
- const timeout = isOriginal ? TIMEOUT_PRIMARY : TIMEOUT_MIRROR;
401
- if (spinner) spinner.text = `\u4ECE ${sourceName} \u4E0B\u8F7D\u6A21\u677F (\u6700\u591A\u91CD\u8BD5${MAX_RETRIES}\u6B21)...`;
402
- const response = await fetchWithRetry(downloadUrl, timeout, MAX_RETRIES);
444
+ if (spinner) spinner.text = `\u8FDE\u63A5 ${source.name} ...`;
445
+ const downloadUrl = source.isDirect ? source.url : buildDownloadUrl(source.url, branch);
446
+ if (spinner) spinner.text = `\u4ECE ${source.name} \u4E0B\u8F7D\u6A21\u677F...`;
447
+ const response = await fetchWithRetry(
448
+ downloadUrl,
449
+ source.timeout,
450
+ MAX_RETRIES,
451
+ source.headers
452
+ );
403
453
  if (spinner) {
404
454
  const len = response.headers.get("content-length");
405
455
  const sizeInfo = len ? `${(parseInt(len) / 1024 / 1024).toFixed(1)}MB ` : "";
406
- spinner.text = `\u4E0B\u8F7D\u4E2D ${sizeInfo}(${sourceName})`;
456
+ spinner.text = `\u4E0B\u8F7D\u4E2D ${sizeInfo}(${source.name})`;
407
457
  }
408
- return { response, sourceName };
458
+ return { response, sourceName: source.name };
409
459
  } catch (error) {
410
- if (i === mirrors.length - 1) throw error;
411
- if (spinner) spinner.text = `${sourceName} \u4E0D\u53EF\u7528\uFF0C\u5207\u6362\u4E0B\u4E00\u4E2A\u6E90...`;
412
- await new Promise((r) => setTimeout(r, 1e3));
460
+ const errMsg = error.message || String(error);
461
+ errors.push(`${source.name}: ${errMsg}`);
462
+ if (i < sources.length - 1 && spinner) {
463
+ spinner.text = `${source.name} \u4E0D\u53EF\u7528\uFF0C\u5207\u6362\u5230 ${sources[i + 1].name}...`;
464
+ await new Promise((r) => setTimeout(r, 500));
465
+ }
413
466
  }
414
467
  }
415
- throw new Error("\u6240\u6709\u4E0B\u8F7D\u6E90\u5747\u4E0D\u53EF\u7528");
468
+ throw new Error(
469
+ `\u6240\u6709\u4E0B\u8F7D\u6E90\u5747\u4E0D\u53EF\u7528:
470
+ ${errors.map((e) => ` - ${e}`).join("\n")}`
471
+ );
472
+ }
473
+ function assertZipBuffer(buffer, sourceName) {
474
+ if (buffer.length < 4 || buffer[0] !== 80 || buffer[1] !== 75) {
475
+ const preview = buffer.slice(0, 100).toString("utf8").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]/g, "\xB7").replace(/\s+/g, " ").slice(0, 80);
476
+ throw new Error(
477
+ `${sourceName} \u8FD4\u56DE\u7684\u4E0D\u662F\u6709\u6548 ZIP \u6587\u4EF6 (\u5185\u5BB9: "${preview}")`
478
+ );
479
+ }
416
480
  }
417
481
  async function downloadTemplate(template, options = {}) {
418
482
  const { spinner, noCache, giteeUrl: optGiteeUrl } = options;
@@ -452,9 +516,11 @@ async function downloadTemplate(template, options = {}) {
452
516
  spinner.text = `\u4E0B\u8F7D\u4E2D [${bar}] ${pct}% ${sizeMB}MB/${totalMB}MB (${sourceName})`;
453
517
  }
454
518
  const buffer = Buffer.concat(chunks);
519
+ assertZipBuffer(buffer, sourceName);
455
520
  await fs.writeFile(tempZip, buffer);
456
521
  } else {
457
522
  const buffer = Buffer.from(await response.arrayBuffer());
523
+ assertZipBuffer(buffer, sourceName);
458
524
  await fs.writeFile(tempZip, buffer);
459
525
  }
460
526
  if (spinner) spinner.text = "\u89E3\u538B\u6A21\u677F\u6587\u4EF6...";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agile-team/robot-cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "现代化项目脚手架工具,支持多技术栈快速创建项目 - 优先 bun,兼容 npm/pnpm/yarn",
5
5
  "type": "module",
6
6
  "bin": {