@agile-team/robot-cli 3.0.2 → 3.0.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 (3) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.js +100 -28
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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.4] - 2026-03-29
9
+
10
+ ### Changed (Breaking)
11
+
12
+ - **彻底重写下载策略 — `git clone --depth=1` 作为主方案**
13
+ - 根本原因分析:HTTP ZIP 下载受到国内网络封锁、Gitee 登录拦截、Content-Type 不一致等各种干扰,是错误的技术路线
14
+ - `git clone --depth=1` 是 create-vue / create-vite / degit / giget 等所有主流 CLI 的底层实现
15
+ - 自动继承用户系统级 git 代理配置 (`http.proxy` / `https.proxy`),无需 CLI 工具自己处理网络问题
16
+ - 实时显示 git 进度条(解析 git stderr 百分比输出)
17
+ - 无超时问题:git 自己管理连接,有数据在流就不断
18
+ - 无 ZIP 解析问题:直接输出目录结构
19
+
20
+ - **优先顺序**:Gitee git clone(国内快)→ GitHub git clone → HTTP ZIP 兜底(仅当系统无 git 时)
21
+
22
+ - **缓存前置**:启动时先检查缓存,命中则直接用,不发起任何网络请求
23
+
24
+ ## [3.0.3] - 2026-03-29
25
+
26
+ ### Fixed
27
+
28
+ - **根治下载超时的真正根因** — `AbortSignal.timeout(ms)` 是总时限(含 body 流式下载),大文件(10-20MB)在国内网络 30 秒内根本下不完,必然中断
29
+ - 改用 `AbortController` + 手动 `clearTimeout`:只对"连接建立/收到响应头"计时(30s),一旦连接成功立即取消计时器,body 流式下载不再受任何时间限制
30
+ - 与 v1.x 的 `node-fetch` `timeout` 行为完全一致(活跃超时,而非总时限)
31
+
8
32
  ## [3.0.2] - 2026-03-29
9
33
 
10
34
  ### Fixed
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { execSync as execSync2 } from "child_process";
18
18
  import fs from "fs-extra";
19
19
  import path from "path";
20
20
  import os from "os";
21
+ import { spawn } from "child_process";
21
22
  import chalk from "chalk";
22
23
  import extract from "extract-zip";
23
24
 
@@ -404,18 +405,21 @@ function buildDownloadSources(repoUrl, branch, giteeUrl) {
404
405
  }
405
406
  return sources;
406
407
  }
407
- async function fetchWithRetry(downloadUrl, timeout, retries, extraHeaders) {
408
+ async function fetchWithRetry(downloadUrl, connectionTimeoutMs, retries, extraHeaders) {
408
409
  let lastError;
409
410
  for (let attempt = 1; attempt <= retries; attempt++) {
411
+ const controller = new AbortController();
412
+ const timer = setTimeout(() => controller.abort(), connectionTimeoutMs);
410
413
  try {
411
414
  const response = await fetch(downloadUrl, {
412
- signal: AbortSignal.timeout(timeout),
415
+ signal: controller.signal,
413
416
  redirect: "follow",
414
417
  headers: {
415
418
  "User-Agent": "Robot-CLI/3.0",
416
419
  ...extraHeaders
417
420
  }
418
421
  });
422
+ clearTimeout(timer);
419
423
  if (!response.ok) {
420
424
  if (response.status === 404) throw new Error(`\u4ED3\u5E93\u4E0D\u5B58\u5728 (404)`);
421
425
  throw new Error(`HTTP ${response.status}`);
@@ -426,6 +430,7 @@ async function fetchWithRetry(downloadUrl, timeout, retries, extraHeaders) {
426
430
  }
427
431
  return response;
428
432
  } catch (error) {
433
+ clearTimeout(timer);
429
434
  lastError = error;
430
435
  if (lastError.message.includes("404")) throw lastError;
431
436
  if (attempt < retries) {
@@ -478,6 +483,43 @@ function assertZipBuffer(buffer, sourceName) {
478
483
  );
479
484
  }
480
485
  }
486
+ async function gitCloneTemplate(repoUrl, branch, targetDir, spinner) {
487
+ return new Promise((resolve, reject) => {
488
+ if (spinner) spinner.text = `\u8FDE\u63A5\u4E2D...`;
489
+ const args = [
490
+ "clone",
491
+ "--depth=1",
492
+ "--single-branch",
493
+ "--branch",
494
+ branch,
495
+ repoUrl,
496
+ targetDir
497
+ ];
498
+ const proc = spawn("git", args, { stdio: ["ignore", "ignore", "pipe"] });
499
+ proc.stderr?.on("data", (chunk) => {
500
+ if (!spinner) return;
501
+ const text3 = chunk.toString();
502
+ const pctMatch = text3.match(/(\d+)%\s*\((\d+)\/(\d+)\)/);
503
+ if (pctMatch) {
504
+ const pct = parseInt(pctMatch[1]);
505
+ const filled = Math.round(pct / 5);
506
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
507
+ const speed = text3.match(/([\d.]+\s*[KMG]iB\/s)/)?.[1] ?? "";
508
+ spinner.text = `\u4E0B\u8F7D\u4E2D [${bar}] ${pct}%${speed ? " " + speed : ""}`;
509
+ } else {
510
+ const line = text3.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "";
511
+ if (line && line.length < 80) spinner.text = `\u514B\u9686\u4E2D... ${line}`;
512
+ }
513
+ });
514
+ proc.on("error", (err) => {
515
+ reject(new Error(err.code === "ENOENT" ? "GIT_NOT_FOUND" : `git \u542F\u52A8\u5931\u8D25: ${err.message}`));
516
+ });
517
+ proc.on("close", (code) => {
518
+ if (code === 0) resolve();
519
+ else reject(new Error(`git clone \u5931\u8D25 (exit ${code})`));
520
+ });
521
+ });
522
+ }
481
523
  async function downloadTemplate(template, options = {}) {
482
524
  const { spinner, noCache, giteeUrl: optGiteeUrl } = options;
483
525
  const branch = template.branch || "main";
@@ -485,15 +527,56 @@ async function downloadTemplate(template, options = {}) {
485
527
  if (!template?.repoUrl) {
486
528
  throw new Error(`\u6A21\u677F\u914D\u7F6E\u65E0\u6548: ${JSON.stringify(template)}`);
487
529
  }
530
+ if (!noCache) {
531
+ const cached = await getCachedTemplate(template.repoUrl);
532
+ if (cached) {
533
+ if (spinner) spinner.text = "\u5DF2\u4F7F\u7528\u7F13\u5B58\u6A21\u677F\uFF0C\u5982\u9700\u66F4\u65B0\u8BF7\u8FD0\u884C robot cache clear";
534
+ return cached;
535
+ }
536
+ }
537
+ const cloneSources = [];
538
+ if (giteeUrl) cloneSources.push({ url: giteeUrl, name: "Gitee" });
539
+ cloneSources.push({ url: template.repoUrl, name: "GitHub" });
540
+ let noGit = false;
541
+ const cloneErrors = [];
542
+ for (const { url: cloneUrl, name: srcName } of cloneSources) {
543
+ const tempDir = path.join(os.tmpdir(), `robot-git-${Date.now()}`);
544
+ try {
545
+ if (spinner) spinner.text = `\u8FDE\u63A5 ${srcName}...`;
546
+ await gitCloneTemplate(cloneUrl, branch, tempDir, spinner);
547
+ await fs.remove(path.join(tempDir, ".git")).catch(() => {
548
+ });
549
+ if (spinner) spinner.text = "\u9A8C\u8BC1\u6A21\u677F\u5B8C\u6574\u6027...";
550
+ if (!fs.existsSync(path.join(tempDir, "package.json"))) {
551
+ throw new Error("\u6A21\u677F\u7F3A\u5C11 package.json");
552
+ }
553
+ if (!noCache) saveToCache(template.repoUrl, tempDir, branch).catch(() => {
554
+ });
555
+ if (spinner) spinner.text = `\u6A21\u677F\u4E0B\u8F7D\u5B8C\u6210 (${srcName})`;
556
+ return tempDir;
557
+ } catch (err) {
558
+ await fs.remove(tempDir).catch(() => {
559
+ });
560
+ const msg = err.message;
561
+ if (msg === "GIT_NOT_FOUND") {
562
+ noGit = true;
563
+ break;
564
+ }
565
+ cloneErrors.push(`${srcName}: ${msg}`);
566
+ if (spinner) spinner.text = `${srcName} \u514B\u9686\u5931\u8D25\uFF0C\u5C1D\u8BD5\u4E0B\u4E00\u4E2A\u6E90...`;
567
+ await new Promise((r) => setTimeout(r, 500));
568
+ }
569
+ }
570
+ if (noGit && spinner) spinner.text = "\u7CFB\u7EDF\u672A\u5B89\u88C5 git\uFF0C\u5207\u6362\u5230 HTTP \u4E0B\u8F7D...";
488
571
  try {
489
- if (spinner) spinner.text = "\u5F00\u59CB\u4E0B\u8F7D\u6700\u65B0\u6A21\u677F...";
572
+ if (spinner) spinner.text = "HTTP \u4E0B\u8F7D\u6A21\u677F\u4E2D...";
490
573
  const { response, sourceName } = await tryDownload(
491
574
  template.repoUrl,
492
575
  branch,
493
576
  spinner,
494
577
  giteeUrl
495
578
  );
496
- if (spinner) spinner.text = "\u4FDD\u5B58\u4E0B\u8F7D\u6587\u4EF6...";
579
+ if (spinner) spinner.text = "\u4FDD\u5B58\u6587\u4EF6...";
497
580
  const timestamp = Date.now();
498
581
  const tempZip = path.join(os.tmpdir(), `robot-template-${timestamp}.zip`);
499
582
  const tempExtract = path.join(os.tmpdir(), `robot-extract-${timestamp}`);
@@ -513,7 +596,7 @@ async function downloadTemplate(template, options = {}) {
513
596
  const bar = "\u2588".repeat(filled) + "\u2591".repeat(20 - filled);
514
597
  const sizeMB = (received / 1024 / 1024).toFixed(1);
515
598
  const totalMB = (totalSize / 1024 / 1024).toFixed(1);
516
- spinner.text = `\u4E0B\u8F7D\u4E2D [${bar}] ${pct}% ${sizeMB}MB/${totalMB}MB (${sourceName})`;
599
+ spinner.text = `\u4E0B\u8F7D\u4E2D [${bar}] ${pct}% ${sizeMB}/${totalMB}MB (${sourceName})`;
517
600
  }
518
601
  const buffer = Buffer.concat(chunks);
519
602
  assertZipBuffer(buffer, sourceName);
@@ -525,59 +608,48 @@ async function downloadTemplate(template, options = {}) {
525
608
  }
526
609
  if (spinner) spinner.text = "\u89E3\u538B\u6A21\u677F\u6587\u4EF6...";
527
610
  await extract(tempZip, { dir: tempExtract });
528
- if (spinner) spinner.text = "\u67E5\u627E\u9879\u76EE\u7ED3\u6784...";
529
611
  const extractedItems = await fs.readdir(tempExtract);
530
612
  const repoName = template.repoUrl.split("/").pop() || "";
531
613
  const projectDir = extractedItems.find(
532
614
  (item) => item === `${repoName}-${branch}` || item.endsWith(`-${branch}`) || item.endsWith("-main") || item.endsWith("-master") || item === repoName
533
615
  );
534
616
  if (!projectDir) {
535
- throw new Error(
536
- `\u89E3\u538B\u540E\u627E\u4E0D\u5230\u9879\u76EE\u76EE\u5F55\uFF0C\u53EF\u7528\u76EE\u5F55: ${extractedItems.join(", ")}`
537
- );
617
+ throw new Error(`\u89E3\u538B\u540E\u627E\u4E0D\u5230\u9879\u76EE\u76EE\u5F55\uFF0C\u53EF\u7528\u76EE\u5F55: ${extractedItems.join(", ")}`);
538
618
  }
539
619
  const sourcePath = path.join(tempExtract, projectDir);
540
- if (spinner) spinner.text = "\u9A8C\u8BC1\u6A21\u677F\u5B8C\u6574\u6027...";
541
620
  if (!fs.existsSync(path.join(sourcePath, "package.json"))) {
542
621
  throw new Error("\u6A21\u677F\u7F3A\u5C11 package.json \u6587\u4EF6");
543
622
  }
544
- if (!noCache) {
545
- saveToCache(template.repoUrl, sourcePath, branch).catch(() => {
546
- });
547
- }
548
- if (spinner) spinner.text = `\u6A21\u677F\u4E0B\u8F7D\u5B8C\u6210 (via ${sourceName})`;
623
+ if (!noCache) saveToCache(template.repoUrl, sourcePath, branch).catch(() => {
624
+ });
625
+ if (spinner) spinner.text = `\u6A21\u677F\u4E0B\u8F7D\u5B8C\u6210 (HTTP/${sourceName})`;
549
626
  await fs.remove(tempZip).catch(() => {
550
627
  });
551
628
  return sourcePath;
552
- } catch (downloadError) {
629
+ } catch (httpError) {
553
630
  if (!noCache) {
554
631
  const cached = await getCachedTemplate(template.repoUrl);
555
632
  if (cached) {
556
633
  if (spinner) spinner.text = "\u7F51\u7EDC\u4E0D\u53EF\u7528\uFF0C\u4F7F\u7528\u7F13\u5B58\u6A21\u677F...";
557
- console.log();
558
- console.log(` ${chalk.yellow(" \u6CE8\u610F: \u4F7F\u7528\u7F13\u5B58\u7248\u672C\uFF08\u975E\u6700\u65B0\uFF09")}`);
634
+ console.log(`
635
+ ${chalk.yellow("\u6CE8\u610F: \u5DF2\u4F7F\u7528\u7F13\u5B58\u7248\u672C\uFF08\u975E\u6700\u65B0\uFF09")}`);
559
636
  return cached;
560
637
  }
561
638
  }
562
639
  try {
563
640
  const tmpFiles = await fs.readdir(os.tmpdir());
564
641
  for (const f of tmpFiles.filter(
565
- (x) => x.includes("robot-template-") || x.includes("robot-extract-")
642
+ (x) => x.includes("robot-template-") || x.includes("robot-extract-") || x.includes("robot-git-")
566
643
  )) {
567
644
  await fs.remove(path.join(os.tmpdir(), f)).catch(() => {
568
645
  });
569
646
  }
570
647
  } catch {
571
648
  }
572
- const errMsg = downloadError.message;
573
- const isTimeout = errMsg.includes("aborted") || errMsg.includes("timeout") || downloadError.name === "TimeoutError";
574
- let msg = `\u6A21\u677F\u4E0B\u8F7D\u5931\u8D25: ${errMsg}`;
575
- if (isTimeout) {
576
- msg += "\n\n \u5EFA\u8BAE:\n 1. \u5F53\u524D\u7F51\u7EDC\u8FDE\u63A5\u8F83\u6162\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\n 2. \u5982\u679C\u5728\u56FD\u5185\uFF0C\u5C1D\u8BD5\u4F7F\u7528\u4EE3\u7406\u6216\u79D1\u5B66\u4E0A\u7F51\n 3. \u4F7F\u7528 robot doctor \u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5";
577
- } else if (downloadError.code === "ENOTFOUND") {
578
- msg += "\n\n \u5EFA\u8BAE:\n 1. \u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u662F\u5426\u6B63\u5E38\n 2. \u5982\u679C\u5728\u56FD\u5185\uFF0C\u5C1D\u8BD5\u4F7F\u7528\u4EE3\u7406\n 3. \u7A0D\u540E\u91CD\u8BD5";
579
- }
580
- throw new Error(msg);
649
+ const errMsg = httpError.message;
650
+ const allErrors = [...cloneErrors, `HTTP: ${errMsg}`].map((e) => ` - ${e}`).join("\n");
651
+ throw new Error(`\u6A21\u677F\u4E0B\u8F7D\u5931\u8D25\uFF0C\u6240\u6709\u65B9\u5F0F\u5747\u4E0D\u53EF\u7528:
652
+ ${allErrors}`);
581
653
  }
582
654
  }
583
655
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agile-team/robot-cli",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "现代化项目脚手架工具,支持多技术栈快速创建项目 - 优先 bun,兼容 npm/pnpm/yarn",
5
5
  "type": "module",
6
6
  "bin": {