@cc123hh/gitee-cli 0.0.0-stage → 0.3.0
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.
- package/LICENSE +21 -0
- package/README.md +218 -3
- package/bin/gitee.js +544 -0
- package/lib/args.js +165 -0
- package/lib/config.js +128 -0
- package/lib/httputil.js +208 -0
- package/package.json +24 -6
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cc123hh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,218 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<h1 align="center">gitee</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/banner.jpg" alt="gitee banner" width="760">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/badges/python.svg" alt="python 3.8+">
|
|
9
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/badges/platform.svg" alt="platform">
|
|
10
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/badges/deps.svg" alt="dependencies">
|
|
11
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/badges/tests.svg" alt="tests">
|
|
12
|
+
<img src="https://gitee.com/cc123hh/gitee-cli/raw/main/docs/badges/license.svg" alt="license">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<b>在终端里把 Gitee 管起来:建仓、推送、看 Issue,一条命令。</b><br>
|
|
17
|
+
零依赖 · 跨平台 · 单文件脚本 · 直接调 Gitee OpenAPI v5
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 📑 目录
|
|
23
|
+
|
|
24
|
+
- [快速开始](#-快速开始)
|
|
25
|
+
- [特性](#-特性)
|
|
26
|
+
- [安装](#-安装)
|
|
27
|
+
- [配置](#-配置)
|
|
28
|
+
- [命令一览](#-命令一览)
|
|
29
|
+
- [常见问题](#-常见问题)
|
|
30
|
+
- [License](#license)
|
|
31
|
+
|
|
32
|
+
## 🚀 快速开始
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ gitee list
|
|
36
|
+
仓库 可见性 ★ 更新
|
|
37
|
+
--------------------------------------------------------------------
|
|
38
|
+
cc123hh/imgen-cli 公开 0 2026-11-04 13:15
|
|
39
|
+
cc123hh/emb-cli 公开 0 2026-11-04 12:40
|
|
40
|
+
cc123hh/private-notes 私有 0 2026-11-03 21:08
|
|
41
|
+
|
|
42
|
+
$ gitee info emb-cli
|
|
43
|
+
仓库: cc123hh/emb-cli
|
|
44
|
+
地址: https://gitee.com/cc123hh/emb-cli
|
|
45
|
+
可见性: 公开
|
|
46
|
+
统计: ★ 0 Fork 0 Watch 1
|
|
47
|
+
默认分支: main
|
|
48
|
+
|
|
49
|
+
$ cd ~/Scripts/new-tool && gitee push --public -d "我的新工具"
|
|
50
|
+
仓库不存在,正在创建 cc123hh/new-tool …
|
|
51
|
+
推送 main → https://gitee.com/cc123hh/new-tool.git …
|
|
52
|
+
已添加 remote origin
|
|
53
|
+
已设为公开
|
|
54
|
+
完成:https://gitee.com/cc123hh/new-tool
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## ✨ 特性
|
|
58
|
+
|
|
59
|
+
| | |
|
|
60
|
+
|---|---|
|
|
61
|
+
| 🪶 **零依赖** | Python 版只用标准库;npm 版纯 Node 内置模块,双实现 |
|
|
62
|
+
| 🖥️ **跨平台** | macOS / Linux / Windows,安装脚本全覆盖 |
|
|
63
|
+
| 📦 **仓库管理** | 列出、查看、创建、删除、公开/私有切换 |
|
|
64
|
+
| 🚀 **建仓推送一体** | `gitee push` 自动建仓 + 推送 + 设可见性 |
|
|
65
|
+
| 📋 **Issue / PR** | 命令行直接看列表与状态 |
|
|
66
|
+
| 🀄 **中文对齐** | 表格按中日韩字符显示宽度排版,不乱 |
|
|
67
|
+
| 🔒 **令牌不落库** | 只从 `GITEE_TOKEN` 读取 |
|
|
68
|
+
|
|
69
|
+
## 📥 安装
|
|
70
|
+
|
|
71
|
+
> 前提:Python 3.8+(npm 版为纯 JS,只需 Node ≥ 16)
|
|
72
|
+
|
|
73
|
+
### macOS / Linux
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git clone https://gitee.com/cc123hh/gitee-cli.git
|
|
77
|
+
cd gitee-cli
|
|
78
|
+
./install.sh
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Windows
|
|
82
|
+
|
|
83
|
+
```powershell
|
|
84
|
+
git clone https://gitee.com/cc123hh/gitee-cli.git
|
|
85
|
+
cd gitee-cli
|
|
86
|
+
powershell -ExecutionPolicy Bypass -File .\install.ps1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
免安装直接用:`gitee.cmd list`
|
|
90
|
+
|
|
91
|
+
### 所有平台(pipx)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pipx install git+https://gitee.com/cc123hh/gitee-cli.git
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 所有平台(npm / pnpm)
|
|
98
|
+
|
|
99
|
+
纯 Node 实现(无需 Python),装完即得 `gitee` 命令:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pnpm add -g @cc123hh/gitee-cli # 或 npm i -g @cc123hh/gitee-cli
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## ⚙️ 配置
|
|
106
|
+
|
|
107
|
+
优先级(高 → 低):**命令行参数(`--token` / `--api` / `--proxy` / `--timeout`)> 内部配置文件 > 环境变量 > 内置默认值**
|
|
108
|
+
|
|
109
|
+
推荐用内部配置持久化(不依赖环境变量注入,GUI 进程 / 非交互 shell 也稳定可用):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
gitee set # 查看当前配置及来源(token 自动脱敏)
|
|
113
|
+
gitee set token <TOKEN> # 设置私人令牌(写入 ~/.config/gitee/config.json,600 权限)
|
|
114
|
+
gitee set proxy <URL> # 设置代理
|
|
115
|
+
gitee unset token # 删除某项
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
环境变量作为兜底层仍然支持:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# 必需(除非已 gitee set token):私人令牌(https://gitee.com/profile/personal_access_tokens)
|
|
122
|
+
export GITEE_TOKEN="你的令牌"
|
|
123
|
+
|
|
124
|
+
# 可选
|
|
125
|
+
export GITEE_API="https://gitee.com/api/v5"
|
|
126
|
+
export GITEE_PROXY="http://127.0.0.1:7890"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Windows:`setx GITEE_TOKEN "你的令牌"`
|
|
130
|
+
|
|
131
|
+
> 令牌权限建议只勾:`projects`(仓库读写)、`pull_requests`、`issues`。
|
|
132
|
+
|
|
133
|
+
## 🧰 命令一览
|
|
134
|
+
|
|
135
|
+
### 账号
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
gitee whoami
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 仓库
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
gitee list # 我的仓库(按更新时间)
|
|
145
|
+
gitee list -n 50 --json # 更多条数 / 原始 JSON
|
|
146
|
+
gitee info emb-cli # 详情
|
|
147
|
+
gitee create my-tool -d "简介" # 新建(默认私有)
|
|
148
|
+
gitee create my-tool --public # 尝试公开(空仓库需推送后生效)
|
|
149
|
+
gitee public my-tool # 设为公开
|
|
150
|
+
gitee private my-tool # 设为私有
|
|
151
|
+
gitee delete my-tool -y # 删除(-y 跳过确认)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 一键建仓并推送
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
cd ~/Scripts/mytool
|
|
158
|
+
gitee push --public -d "我的小工具"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
会自动:检查仓库是否存在 → 不存在则创建 → `git push` → 添加 `origin` → 需要时设为公开。
|
|
162
|
+
|
|
163
|
+
### Issue / Pull Request
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
gitee issues emb-cli # 打开中的 Issue
|
|
167
|
+
gitee issues emb-cli -s all -n 50
|
|
168
|
+
gitee prs emb-cli
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`repo` 参数写 `仓库名` 会自动补上你的用户名,也可以写完整的 `owner/repo`。
|
|
172
|
+
|
|
173
|
+
## 📋 参数
|
|
174
|
+
|
|
175
|
+
| 参数 | 适用命令 | 说明 |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| `-n/--limit` | list / issues / prs | 返回条数 |
|
|
178
|
+
| `--json` | list | 输出原始 JSON |
|
|
179
|
+
| `-s/--state` | issues / prs | `open` / `closed` / `all` |
|
|
180
|
+
| `-y/--yes` | delete | 跳过确认 |
|
|
181
|
+
| `-d/--description` | create / push | 仓库简介 |
|
|
182
|
+
| `--public` | create / push | 目标可见性 |
|
|
183
|
+
| `--name` | push | 仓库名(默认取目录名) |
|
|
184
|
+
| `--api/--proxy/--timeout` | 全部 | 覆盖环境变量 |
|
|
185
|
+
|
|
186
|
+
## ❓ 常见问题
|
|
187
|
+
|
|
188
|
+
<details>
|
|
189
|
+
<summary><b>提示鉴权失败(401/403)?</b></summary>
|
|
190
|
+
|
|
191
|
+
检查 `GITEE_TOKEN` 是否有效、是否勾选 `projects` 权限;令牌过期请重新生成。
|
|
192
|
+
</details>
|
|
193
|
+
|
|
194
|
+
<details>
|
|
195
|
+
<summary><b>创建时选了公开,结果还是私有?</b></summary>
|
|
196
|
+
|
|
197
|
+
Gitee 限制空仓库不能设为公开。推送首个提交后再执行 `gitee public 仓库名`;用 `gitee push --public` 会自动在推送后切换。
|
|
198
|
+
</details>
|
|
199
|
+
|
|
200
|
+
<details>
|
|
201
|
+
<summary><b>push 报「不是 git 仓库」?</b></summary>
|
|
202
|
+
|
|
203
|
+
`gitee push` 不会替你 `git init`,只负责建仓与推送。请先初始化并提交。
|
|
204
|
+
</details>
|
|
205
|
+
|
|
206
|
+
<details>
|
|
207
|
+
<summary><b>表格里中文对不齐?</b></summary>
|
|
208
|
+
|
|
209
|
+
已按 `unicodedata.east_asian_width` 计算显示宽度;若终端字体非等宽仍可能有偏差,属字体问题。
|
|
210
|
+
</details>
|
|
211
|
+
|
|
212
|
+
## 🤝 贡献
|
|
213
|
+
|
|
214
|
+
欢迎 Issue / PR。
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
[MIT](https://gitee.com/cc123hh/gitee-cli/blob/main/LICENSE)
|
package/bin/gitee.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* gitee — Gitee 仓库管理命令行工具(纯 Node 零依赖,调 Gitee OpenAPI v5)
|
|
5
|
+
*
|
|
6
|
+
* 用法示例:
|
|
7
|
+
* gitee list # 列出我的仓库(按更新时间排序)
|
|
8
|
+
* gitee info cc123hh/imgen-cli # 查看某个仓库
|
|
9
|
+
* gitee create my-tool -d "说明" # 新建仓库(默认私有)
|
|
10
|
+
* gitee public my-tool # 设为公开
|
|
11
|
+
* gitee delete my-tool -y # 删除仓库
|
|
12
|
+
* gitee push --public # 当前目录建仓并推送
|
|
13
|
+
*
|
|
14
|
+
* 配置优先级(高 → 低):
|
|
15
|
+
* 1. 命令行参数(--token / --api / --proxy / --timeout,临时覆盖)
|
|
16
|
+
* 2. 内部配置文件(gitee set 写入,持久生效,推荐)
|
|
17
|
+
* 3. 环境变量(GITEE_TOKEN / GITEE_API / GITEE_PROXY)
|
|
18
|
+
* 4. 内置默认值
|
|
19
|
+
*/
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const spawnSync = require("child_process").spawnSync;
|
|
23
|
+
const readline = require("readline");
|
|
24
|
+
const { homedir, normKey, loadConfig, saveConfig, displayValue,
|
|
25
|
+
resolveValue, showConfig } = require("../lib/config.js");
|
|
26
|
+
const { requestOnce, TimeoutError } = require("../lib/httputil.js");
|
|
27
|
+
const { parse } = require("../lib/args.js");
|
|
28
|
+
|
|
29
|
+
const DEFAULT_API = "https://gitee.com/api/v5";
|
|
30
|
+
const DEFAULT_TIMEOUT = 30;
|
|
31
|
+
const EXIT_OK = 0, EXIT_USAGE = 2, EXIT_AUTH = 3, EXIT_API = 5, EXIT_GIT = 6;
|
|
32
|
+
|
|
33
|
+
const CONFIG_PATH = path.join(homedir(), ".config", "gitee", "config.json");
|
|
34
|
+
const CONFIG_KEYS = ["token", "api", "proxy", "timeout"];
|
|
35
|
+
|
|
36
|
+
// --------------------------------------------------------------------------- //
|
|
37
|
+
// 内部配置管理(gitee set / gitee unset)
|
|
38
|
+
// --------------------------------------------------------------------------- //
|
|
39
|
+
function configRows(cfg) {
|
|
40
|
+
return [
|
|
41
|
+
{ label: "token", key: "token", env: "GITEE_TOKEN", default: null, secret: true, cfg },
|
|
42
|
+
{ label: "api", key: "api", env: "GITEE_API", default: DEFAULT_API, cfg },
|
|
43
|
+
{ label: "proxy", key: "proxy", env: "GITEE_PROXY", default: null, cfg },
|
|
44
|
+
{ label: "timeout", key: "timeout", env: null, default: DEFAULT_TIMEOUT, cfg },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cmdSetConfig(opts) {
|
|
49
|
+
if (opts.key === undefined || opts.key === null) {
|
|
50
|
+
showConfig(configRows(loadConfig(CONFIG_PATH, CONFIG_KEYS)), CONFIG_PATH);
|
|
51
|
+
return EXIT_OK;
|
|
52
|
+
}
|
|
53
|
+
if (opts.value === undefined || opts.value === null) {
|
|
54
|
+
console.error(`用法:gitee set <key> <value>(可用 key:${CONFIG_KEYS.join(" / ")},`
|
|
55
|
+
+ "不带参数列出当前配置)");
|
|
56
|
+
return EXIT_USAGE;
|
|
57
|
+
}
|
|
58
|
+
const key = normKey(opts.key, CONFIG_KEYS);
|
|
59
|
+
if (!key) {
|
|
60
|
+
console.error(`错误:未知配置项 ${JSON.stringify(opts.key)},可用:${CONFIG_KEYS.join(" / ")}`);
|
|
61
|
+
return EXIT_USAGE;
|
|
62
|
+
}
|
|
63
|
+
let value = opts.value;
|
|
64
|
+
if (key === "timeout") {
|
|
65
|
+
const n = Number(opts.value);
|
|
66
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
67
|
+
console.error(`错误:timeout 必须为正整数,收到 ${JSON.stringify(opts.value)}`);
|
|
68
|
+
return EXIT_USAGE;
|
|
69
|
+
}
|
|
70
|
+
value = n;
|
|
71
|
+
} else if (key === "api") {
|
|
72
|
+
value = opts.value.replace(/\/+$/, "");
|
|
73
|
+
}
|
|
74
|
+
const cfg = loadConfig(CONFIG_PATH, CONFIG_KEYS);
|
|
75
|
+
cfg[key] = value;
|
|
76
|
+
saveConfig(CONFIG_PATH, cfg);
|
|
77
|
+
console.log(`✅ 已写入 ${key} = ${displayValue(key === "token", value)}`);
|
|
78
|
+
console.log(` 配置文件:${CONFIG_PATH}`);
|
|
79
|
+
return EXIT_OK;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cmdUnsetConfig(opts) {
|
|
83
|
+
const key = normKey(opts.key, CONFIG_KEYS);
|
|
84
|
+
if (!key) {
|
|
85
|
+
console.error(`错误:未知配置项 ${JSON.stringify(opts.key)},可用:${CONFIG_KEYS.join(" / ")}`);
|
|
86
|
+
return EXIT_USAGE;
|
|
87
|
+
}
|
|
88
|
+
const cfg = loadConfig(CONFIG_PATH, CONFIG_KEYS);
|
|
89
|
+
if (!(key in cfg)) {
|
|
90
|
+
console.error(`错误:${opts.key} 未在内部配置中设置(配置文件:${CONFIG_PATH})`);
|
|
91
|
+
return EXIT_USAGE;
|
|
92
|
+
}
|
|
93
|
+
delete cfg[key];
|
|
94
|
+
saveConfig(CONFIG_PATH, cfg);
|
|
95
|
+
console.log(`✅ 已删除配置项 ${key}`);
|
|
96
|
+
return EXIT_OK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --------------------------------------------------------------------------- //
|
|
100
|
+
// HTTP 客户端
|
|
101
|
+
// --------------------------------------------------------------------------- //
|
|
102
|
+
class GiteeError extends Error {}
|
|
103
|
+
|
|
104
|
+
class Client {
|
|
105
|
+
constructor(token, api, proxy, timeout) {
|
|
106
|
+
this.token = token;
|
|
107
|
+
this.api = api.replace(/\/+$/, "");
|
|
108
|
+
this.proxy = proxy;
|
|
109
|
+
this.timeout = timeout;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async _request(method, path, params, form) {
|
|
113
|
+
const q = new URLSearchParams();
|
|
114
|
+
for (const [k, v] of Object.entries(params || {})) q.set(k, String(v));
|
|
115
|
+
q.set("access_token", this.token);
|
|
116
|
+
const urlStr = `${this.api}${path}?${q.toString()}`;
|
|
117
|
+
const headers = {};
|
|
118
|
+
let body = null;
|
|
119
|
+
if (form) {
|
|
120
|
+
const f = new URLSearchParams();
|
|
121
|
+
for (const [k, v] of Object.entries(form)) f.set(k, String(v));
|
|
122
|
+
body = Buffer.from(f.toString(), "utf8");
|
|
123
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
124
|
+
}
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await requestOnce({
|
|
128
|
+
urlStr, method, headers, body, proxy: this.proxy, timeoutSec: this.timeout,
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
const reason = e instanceof TimeoutError ? "timed out" : e.message;
|
|
132
|
+
throw new GiteeError(`网络错误:${reason}(可设置 GITEE_PROXY 走代理)`);
|
|
133
|
+
}
|
|
134
|
+
const text = res.body.toString("utf8");
|
|
135
|
+
if (res.status >= 400) {
|
|
136
|
+
const detail = text.slice(0, 300);
|
|
137
|
+
if (res.status === 401 || res.status === 403) {
|
|
138
|
+
throw new GiteeError(`鉴权失败(HTTP ${res.status}):检查 GITEE_TOKEN 是否有效`
|
|
139
|
+
+ ` / 权限是否足够。${detail}`);
|
|
140
|
+
}
|
|
141
|
+
if (res.status === 404) {
|
|
142
|
+
throw new GiteeError(`资源不存在(HTTP 404):${path}`);
|
|
143
|
+
}
|
|
144
|
+
throw new GiteeError(`HTTP ${res.status}:${detail}`);
|
|
145
|
+
}
|
|
146
|
+
return text.trim() ? JSON.parse(text) : {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get(path, params) { return this._request("GET", path, params); }
|
|
150
|
+
post(path, form) { return this._request("POST", path, null, form); }
|
|
151
|
+
patch(path, form) { return this._request("PATCH", path, null, form); }
|
|
152
|
+
delete(path) { return this._request("DELETE", path); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------------------------------------- //
|
|
156
|
+
// 终端对齐辅助
|
|
157
|
+
// --------------------------------------------------------------------------- //
|
|
158
|
+
function width(text) {
|
|
159
|
+
let n = 0;
|
|
160
|
+
for (const ch of text) n += ch.codePointAt(0) > 0x2e7f ? 2 : 1;
|
|
161
|
+
return n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function pad(text, w) {
|
|
165
|
+
return text + " ".repeat(Math.max(0, w - width(text)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --------------------------------------------------------------------------- //
|
|
169
|
+
// 命令实现
|
|
170
|
+
// --------------------------------------------------------------------------- //
|
|
171
|
+
async function cmdWhoami(client) {
|
|
172
|
+
const user = await client.get("/user");
|
|
173
|
+
console.log(`${user.login} (${user.name || "未设置昵称"})`);
|
|
174
|
+
console.log(` 主页: ${user.html_url}`);
|
|
175
|
+
console.log(` 邮箱: ${user.email || "未公开"}`);
|
|
176
|
+
return EXIT_OK;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function cmdList(client, opts) {
|
|
180
|
+
const repos = await client.get("/user/repos", {
|
|
181
|
+
sort: "updated", per_page: String(opts.limit), page: "1",
|
|
182
|
+
});
|
|
183
|
+
if (!repos.length) {
|
|
184
|
+
console.log("没有仓库");
|
|
185
|
+
return EXIT_OK;
|
|
186
|
+
}
|
|
187
|
+
if (opts.json) {
|
|
188
|
+
console.log(JSON.stringify(repos, null, 2));
|
|
189
|
+
return EXIT_OK;
|
|
190
|
+
}
|
|
191
|
+
const nameW = Math.max(...repos.map((r) => width(r.full_name || "")), 8) + 2;
|
|
192
|
+
const header = pad("仓库", nameW) + pad("可见性", 8) + " ★ 更新";
|
|
193
|
+
console.log(header);
|
|
194
|
+
console.log("-".repeat(width(header)));
|
|
195
|
+
for (const repo of repos) {
|
|
196
|
+
const vis = repo.private ? "私有" : "公开";
|
|
197
|
+
const stars = String(repo.stargazers_count ?? 0).padStart(3);
|
|
198
|
+
const updated = (repo.updated_at || "").slice(0, 16).replace("T", " ");
|
|
199
|
+
console.log(pad(repo.full_name || "", nameW) + pad(vis, 8) + `${stars} ${updated}`);
|
|
200
|
+
}
|
|
201
|
+
return EXIT_OK;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function splitRepo(name, client) {
|
|
205
|
+
// 把 'repo' 或 'owner/repo' 规范成 'owner/repo'
|
|
206
|
+
if (name.includes("/")) return name;
|
|
207
|
+
const user = await client.get("/user");
|
|
208
|
+
return `${user.login}/${name}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function cmdInfo(client, opts) {
|
|
212
|
+
const full = await splitRepo(opts.repo, client);
|
|
213
|
+
const repo = await client.get(`/repos/${full}`);
|
|
214
|
+
console.log(`仓库: ${repo.full_name}`);
|
|
215
|
+
console.log(`地址: ${repo.html_url}`);
|
|
216
|
+
console.log(`可见性: ${repo.private ? "私有" : "公开"}`);
|
|
217
|
+
console.log(`描述: ${repo.description || "(无)"}`);
|
|
218
|
+
console.log(`统计: ★ ${repo.stargazers_count ?? 0} `
|
|
219
|
+
+ `Fork ${repo.forks_count ?? 0} `
|
|
220
|
+
+ `Watch ${repo.watchers_count ?? 0}`);
|
|
221
|
+
console.log(`默认分支: ${repo.default_branch}`);
|
|
222
|
+
console.log(`更新于: ${repo.updated_at}`);
|
|
223
|
+
return EXIT_OK;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function cmdCreate(client, opts) {
|
|
227
|
+
const user = await client.get("/user");
|
|
228
|
+
const owner = user.login;
|
|
229
|
+
const form = { name: opts.name, private: opts.public ? "false" : "true" };
|
|
230
|
+
if (opts.description) form.description = opts.description;
|
|
231
|
+
const repo = await client.post("/user/repos", form);
|
|
232
|
+
const full = repo.full_name || `${owner}/${opts.name}`;
|
|
233
|
+
const actualPrivate = repo.private !== false;
|
|
234
|
+
console.log(`已创建: ${full}`);
|
|
235
|
+
if (opts.public && actualPrivate) {
|
|
236
|
+
console.log(`提示:Gitee 不允许空仓库直接设为公开,推送首个提交后执行 `
|
|
237
|
+
+ `\`gitee public ${opts.name}\` 即可。`);
|
|
238
|
+
}
|
|
239
|
+
return EXIT_OK;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function cmdSetVisibility(client, opts, privateMode) {
|
|
243
|
+
const full = await splitRepo(opts.repo, client);
|
|
244
|
+
await client.patch(`/repos/${full}`, {
|
|
245
|
+
name: full.split("/")[1],
|
|
246
|
+
private: privateMode ? "true" : "false",
|
|
247
|
+
});
|
|
248
|
+
const repo = await client.get(`/repos/${full}`);
|
|
249
|
+
console.log(`${full} 现在是${repo.private ? "私有" : "公开"}仓库`);
|
|
250
|
+
return EXIT_OK;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function ask(question) {
|
|
254
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
255
|
+
try {
|
|
256
|
+
return (await rl.question(question)).trim();
|
|
257
|
+
} finally {
|
|
258
|
+
rl.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function cmdDelete(client, opts) {
|
|
263
|
+
const full = await splitRepo(opts.repo, client);
|
|
264
|
+
if (!opts.yes) {
|
|
265
|
+
const answer = await ask(`确认删除 ${full}?(不可恢复,输入 yes 继续):`);
|
|
266
|
+
if (answer !== "yes") {
|
|
267
|
+
console.log("已取消");
|
|
268
|
+
return EXIT_OK;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await client.delete(`/repos/${full}`);
|
|
272
|
+
console.log(`已删除 ${full}`);
|
|
273
|
+
return EXIT_OK;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function parseRepoArg(opts, client) {
|
|
277
|
+
const name = opts.repo !== undefined && opts.repo !== null
|
|
278
|
+
? opts.repo
|
|
279
|
+
: path.basename(process.cwd());
|
|
280
|
+
return splitRepo(name, client);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function cmdIssues(client, opts) {
|
|
284
|
+
const full = await parseRepoArg(opts, client);
|
|
285
|
+
const items = await client.get(`/repos/${full}/issues`, {
|
|
286
|
+
state: opts.state, per_page: String(opts.limit),
|
|
287
|
+
});
|
|
288
|
+
if (!items.length) {
|
|
289
|
+
console.log(`${full}: 没有 Issue`);
|
|
290
|
+
return EXIT_OK;
|
|
291
|
+
}
|
|
292
|
+
for (const it of items) {
|
|
293
|
+
const user = (it.user && it.user.login) || "?";
|
|
294
|
+
console.log(`#${String(it.number).padEnd(5)} [${String(it.state).padEnd(6)}] `
|
|
295
|
+
+ `${it.title} @${user}`);
|
|
296
|
+
}
|
|
297
|
+
return EXIT_OK;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function cmdPrs(client, opts) {
|
|
301
|
+
const full = await parseRepoArg(opts, client);
|
|
302
|
+
const items = await client.get(`/repos/${full}/pulls`, {
|
|
303
|
+
state: opts.state, per_page: String(opts.limit),
|
|
304
|
+
});
|
|
305
|
+
if (!items.length) {
|
|
306
|
+
console.log(`${full}: 没有 Pull Request`);
|
|
307
|
+
return EXIT_OK;
|
|
308
|
+
}
|
|
309
|
+
for (const it of items) {
|
|
310
|
+
const user = (it.user && it.user.login) || "?";
|
|
311
|
+
console.log(`#${String(it.number).padEnd(5)} [${String(it.state).padEnd(6)}] `
|
|
312
|
+
+ `${it.title} @${user}`);
|
|
313
|
+
}
|
|
314
|
+
return EXIT_OK;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function runGit(args, cwd) {
|
|
318
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
319
|
+
return { code: r.status, out: r.stdout || "", err: r.stderr || "" };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function cmdPush(client, opts) {
|
|
323
|
+
const cwd = process.cwd();
|
|
324
|
+
const dotGit = path.join(cwd, ".git");
|
|
325
|
+
if (!fs.existsSync(dotGit) || !fs.statSync(dotGit).isDirectory()) {
|
|
326
|
+
console.error("当前目录不是 git 仓库(缺少 .git)");
|
|
327
|
+
return EXIT_GIT;
|
|
328
|
+
}
|
|
329
|
+
const user = await client.get("/user");
|
|
330
|
+
const owner = user.login;
|
|
331
|
+
const name = opts.name || path.basename(cwd);
|
|
332
|
+
const full = `${owner}/${name}`;
|
|
333
|
+
|
|
334
|
+
let exists = true;
|
|
335
|
+
try {
|
|
336
|
+
await client.get(`/repos/${full}`);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (e instanceof GiteeError && e.message.includes("404")) {
|
|
339
|
+
exists = false;
|
|
340
|
+
} else {
|
|
341
|
+
throw e;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!exists) {
|
|
345
|
+
console.log(`仓库不存在,正在创建 ${full} …`);
|
|
346
|
+
await client.post("/user/repos", {
|
|
347
|
+
name,
|
|
348
|
+
private: opts.public ? "false" : "true",
|
|
349
|
+
description: opts.description || "",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd).out.trim() || "master";
|
|
354
|
+
const remoteUrl = `https://gitee.com/${full}.git`;
|
|
355
|
+
const authUrl = `https://oauth2:${client.token}@gitee.com/${full}.git`;
|
|
356
|
+
|
|
357
|
+
console.log(`推送 ${branch} → ${remoteUrl} …`);
|
|
358
|
+
const result = runGit(["push", authUrl, `${branch}:${branch}`], cwd);
|
|
359
|
+
if (result.code !== 0) {
|
|
360
|
+
// 令牌不落终端:git 报错输出可能带完整 auth_url,先把 token 打码
|
|
361
|
+
console.error(`推送失败:${result.err.trim().replaceAll(client.token, "***").slice(0, 500)}`);
|
|
362
|
+
return EXIT_GIT;
|
|
363
|
+
}
|
|
364
|
+
if (runGit(["remote", "get-url", "origin"], cwd).code !== 0) {
|
|
365
|
+
runGit(["remote", "add", "origin", remoteUrl], cwd);
|
|
366
|
+
console.log("已添加 remote origin");
|
|
367
|
+
}
|
|
368
|
+
if (opts.public) {
|
|
369
|
+
await client.patch(`/repos/${full}`, { name, private: "false" });
|
|
370
|
+
console.log("已设为公开");
|
|
371
|
+
}
|
|
372
|
+
console.log(`完成:https://gitee.com/${full}`);
|
|
373
|
+
return EXIT_OK;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --------------------------------------------------------------------------- //
|
|
377
|
+
// 子命令表与入口
|
|
378
|
+
// --------------------------------------------------------------------------- //
|
|
379
|
+
function clientOpts() {
|
|
380
|
+
return [
|
|
381
|
+
{ long: "--token", dest: "token", arg: "value", help: "私人令牌(优先级最高,临时用别的账号时用;注意会进 shell 历史)" },
|
|
382
|
+
{ long: "--api", dest: "api", arg: "value", help: "API 根地址(优先级:命令行 > gitee set > GITEE_API > 官方地址)" },
|
|
383
|
+
{ long: "--proxy", dest: "proxy", arg: "value", help: "代理地址(优先级:命令行 > gitee set > GITEE_PROXY)" },
|
|
384
|
+
{ long: "--timeout", dest: "timeout", arg: "int", help: "超时秒数(优先级:命令行 > gitee set > 30)" },
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function spec(usage, description, options, positionals) {
|
|
389
|
+
return { prog: "gitee", usage, description, options, positionals: positionals || [] };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const STATE_OPTS = [
|
|
393
|
+
{ short: "-s", long: "--state", dest: "state", arg: "value", default: "open",
|
|
394
|
+
choices: ["open", "closed", "all"], help: "状态(默认 open)" },
|
|
395
|
+
{ short: "-n", long: "--limit", dest: "limit", arg: "int", default: 20, help: "条数(默认 20)" },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
const SUBCOMMANDS = {
|
|
399
|
+
set: {
|
|
400
|
+
run: (opts) => cmdSetConfig(opts),
|
|
401
|
+
spec: spec("用法:gitee set [<key> <value>]",
|
|
402
|
+
"查看或写入内部配置(~/.config/gitee/config.json)",
|
|
403
|
+
[], [
|
|
404
|
+
{ dest: "key", required: false, help: "配置项:token / api / proxy / timeout" },
|
|
405
|
+
{ dest: "value", required: false, help: "要写入的值(不带任何参数时列出当前配置)" },
|
|
406
|
+
]),
|
|
407
|
+
noAuth: true,
|
|
408
|
+
},
|
|
409
|
+
unset: {
|
|
410
|
+
run: (opts) => cmdUnsetConfig(opts),
|
|
411
|
+
spec: spec("用法:gitee unset <key>", "删除一项内部配置",
|
|
412
|
+
[], [{ dest: "key", required: true, help: "配置项:token / api / proxy / timeout" }]),
|
|
413
|
+
noAuth: true,
|
|
414
|
+
},
|
|
415
|
+
whoami: {
|
|
416
|
+
run: (client) => cmdWhoami(client),
|
|
417
|
+
spec: spec("用法:gitee whoami", "查看当前 token 对应的账号", clientOpts()),
|
|
418
|
+
},
|
|
419
|
+
list: {
|
|
420
|
+
run: (client, o) => cmdList(client, o),
|
|
421
|
+
spec: spec("用法:gitee list [-n 条数] [--json]", "列出我的仓库",
|
|
422
|
+
[...STATE_OPTS.slice(1), { long: "--json", dest: "json", help: "输出原始 JSON" },
|
|
423
|
+
...clientOpts()]),
|
|
424
|
+
},
|
|
425
|
+
info: {
|
|
426
|
+
run: (client, o) => cmdInfo(client, o),
|
|
427
|
+
spec: spec("用法:gitee info <repo>", "查看仓库信息", clientOpts(),
|
|
428
|
+
[{ dest: "repo", required: true, help: "仓库名或 owner/repo" }]),
|
|
429
|
+
},
|
|
430
|
+
create: {
|
|
431
|
+
run: (client, o) => cmdCreate(client, o),
|
|
432
|
+
spec: spec("用法:gitee create <name> [-d 简介] [--public]", "新建仓库",
|
|
433
|
+
[{ short: "-d", long: "--description", dest: "description", arg: "value", help: "仓库简介" },
|
|
434
|
+
{ long: "--public", dest: "public", help: "设为公开(空仓库需推送后生效)" },
|
|
435
|
+
...clientOpts()],
|
|
436
|
+
[{ dest: "name", required: true, help: "仓库名" }]),
|
|
437
|
+
},
|
|
438
|
+
public: {
|
|
439
|
+
run: (client, o) => cmdSetVisibility(client, o, false),
|
|
440
|
+
spec: spec("用法:gitee public <repo>", "把仓库设为公开", clientOpts(),
|
|
441
|
+
[{ dest: "repo", required: true, help: "仓库名或 owner/repo" }]),
|
|
442
|
+
},
|
|
443
|
+
private: {
|
|
444
|
+
run: (client, o) => cmdSetVisibility(client, o, true),
|
|
445
|
+
spec: spec("用法:gitee private <repo>", "把仓库设为私有", clientOpts(),
|
|
446
|
+
[{ dest: "repo", required: true, help: "仓库名或 owner/repo" }]),
|
|
447
|
+
},
|
|
448
|
+
delete: {
|
|
449
|
+
run: (client, o) => cmdDelete(client, o),
|
|
450
|
+
spec: spec("用法:gitee delete <repo> [-y]", "删除仓库",
|
|
451
|
+
[{ short: "-y", long: "--yes", dest: "yes", help: "跳过确认" }, ...clientOpts()],
|
|
452
|
+
[{ dest: "repo", required: true, help: "仓库名或 owner/repo" }]),
|
|
453
|
+
},
|
|
454
|
+
issues: {
|
|
455
|
+
run: (client, o) => cmdIssues(client, o),
|
|
456
|
+
spec: spec("用法:gitee issues [<repo>] [-s 状态] [-n 条数]", "列出 Issue",
|
|
457
|
+
[...STATE_OPTS, ...clientOpts()],
|
|
458
|
+
[{ dest: "repo", required: false, help: "仓库名或 owner/repo(默认当前目录同名仓库)" }]),
|
|
459
|
+
},
|
|
460
|
+
prs: {
|
|
461
|
+
run: (client, o) => cmdPrs(client, o),
|
|
462
|
+
spec: spec("用法:gitee prs [<repo>] [-s 状态] [-n 条数]", "列出 Pull Request",
|
|
463
|
+
[...STATE_OPTS, ...clientOpts()],
|
|
464
|
+
[{ dest: "repo", required: false, help: "仓库名或 owner/repo(默认当前目录同名仓库)" }]),
|
|
465
|
+
},
|
|
466
|
+
push: {
|
|
467
|
+
run: (client, o) => cmdPush(client, o),
|
|
468
|
+
spec: spec("用法:gitee push [--name 名字] [-d 简介] [--public]", "当前目录建仓并推送",
|
|
469
|
+
[{ long: "--name", dest: "name", arg: "value", help: "仓库名(默认取目录名)" },
|
|
470
|
+
{ short: "-d", long: "--description", dest: "description", arg: "value", help: "仓库简介" },
|
|
471
|
+
{ long: "--public", dest: "public", help: "推送后设为公开" },
|
|
472
|
+
...clientOpts()]),
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const TOP_HELP = `gitee — Gitee 仓库管理命令行工具
|
|
477
|
+
|
|
478
|
+
用法:gitee <子命令> [参数]
|
|
479
|
+
|
|
480
|
+
子命令:
|
|
481
|
+
set / unset 内部配置管理(token / api / proxy / timeout)
|
|
482
|
+
whoami 查看当前 token 对应的账号
|
|
483
|
+
list 列出我的仓库
|
|
484
|
+
info <repo> 查看仓库信息
|
|
485
|
+
create 新建仓库
|
|
486
|
+
public/private 切换仓库可见性
|
|
487
|
+
delete 删除仓库
|
|
488
|
+
issues / prs 列出 Issue / Pull Request
|
|
489
|
+
push 当前目录建仓并推送
|
|
490
|
+
|
|
491
|
+
用 \`gitee <子命令> -h\` 查看各子命令详情。`;
|
|
492
|
+
|
|
493
|
+
async function main() {
|
|
494
|
+
const argv = process.argv.slice(2);
|
|
495
|
+
const cmd = argv[0];
|
|
496
|
+
if (!cmd) {
|
|
497
|
+
console.error(`错误:缺少子命令\n\n${TOP_HELP}`);
|
|
498
|
+
process.exit(EXIT_USAGE);
|
|
499
|
+
}
|
|
500
|
+
if (cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
501
|
+
console.log(TOP_HELP);
|
|
502
|
+
process.exit(EXIT_OK);
|
|
503
|
+
}
|
|
504
|
+
const def = SUBCOMMANDS[cmd];
|
|
505
|
+
if (!def) {
|
|
506
|
+
console.error(`错误:未知子命令 ${JSON.stringify(cmd)}\n\n${TOP_HELP}`);
|
|
507
|
+
process.exit(EXIT_USAGE);
|
|
508
|
+
}
|
|
509
|
+
if (cmd === "-h") process.exit(EXIT_OK);
|
|
510
|
+
|
|
511
|
+
const { opts } = parse(def.spec, argv.slice(1));
|
|
512
|
+
if (def.noAuth) {
|
|
513
|
+
process.exit(await def.run(opts));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const cfg = loadConfig(CONFIG_PATH, CONFIG_KEYS);
|
|
517
|
+
const [tokenRaw] = resolveValue(opts.token, cfg.token, "GITEE_TOKEN", "");
|
|
518
|
+
const token = String(tokenRaw || "").trim();
|
|
519
|
+
if (!token) {
|
|
520
|
+
console.error("错误:未设置 Gitee 私人令牌,请先执行:gitee set token 你的令牌"
|
|
521
|
+
+ "(或 export GITEE_TOKEN=你的令牌)");
|
|
522
|
+
process.exit(EXIT_AUTH);
|
|
523
|
+
}
|
|
524
|
+
const [apiRaw] = resolveValue(opts.api, cfg.api, "GITEE_API", DEFAULT_API);
|
|
525
|
+
const api = apiRaw.replace(/\/+$/, "");
|
|
526
|
+
const [proxy] = resolveValue(opts.proxy, cfg.proxy, "GITEE_PROXY", null);
|
|
527
|
+
const [timeout] = resolveValue(opts.timeout, cfg.timeout, null, DEFAULT_TIMEOUT);
|
|
528
|
+
const client = new Client(token, api, proxy, timeout);
|
|
529
|
+
try {
|
|
530
|
+
process.exit(await def.run(client, opts));
|
|
531
|
+
} catch (e) {
|
|
532
|
+
if (e instanceof GiteeError) {
|
|
533
|
+
console.error(`错误:${e.message}`);
|
|
534
|
+
process.exit(EXIT_API);
|
|
535
|
+
}
|
|
536
|
+
console.error(`错误:${e.message}`);
|
|
537
|
+
process.exit(EXIT_API);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
main().catch((e) => {
|
|
542
|
+
console.error(`错误:${e.message}`);
|
|
543
|
+
process.exit(EXIT_API);
|
|
544
|
+
});
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 极简参数解析器(零依赖,覆盖本项目用到的 argparse 子集):
|
|
4
|
+
* 短/长选项、--opt=value、可重复选项、开关、整数、choices、
|
|
5
|
+
* 位置参数(必填/可选)、-h/--help。错误打印用法并 exit 2(对齐 argparse 约定)。
|
|
6
|
+
*
|
|
7
|
+
* spec: {
|
|
8
|
+
* prog, usage, description,
|
|
9
|
+
* options: [{ short, long, dest, arg: false|"value"|"int", repeat, choices, default, required, help }],
|
|
10
|
+
* positionals: [{ dest, required, help }],
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
function die(spec, msg) {
|
|
14
|
+
console.error(`${msg}\n\n${usageText(spec)}`);
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function optionNames(o) {
|
|
19
|
+
return [o.short, o.long].filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function valueLabel(o) {
|
|
23
|
+
if (!o.arg) return "";
|
|
24
|
+
if (o.arg === "int") return " N";
|
|
25
|
+
const m = (o.long || o.short || "").replace(/^--?/, "").toUpperCase();
|
|
26
|
+
return ` ${m}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function usageText(spec) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (const o of spec.options) {
|
|
32
|
+
if (o.required) {
|
|
33
|
+
parts.push(`${optionNames(o).join(", ")}${valueLabel(o)}`);
|
|
34
|
+
} else if (!o.arg) {
|
|
35
|
+
parts.push(`[${optionNames(o)[0]}]`);
|
|
36
|
+
} else {
|
|
37
|
+
parts.push(`[${optionNames(o).join(", ")}${valueLabel(o)}]`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const pos = (spec.positionals || [])
|
|
41
|
+
.map((p) => (p.required ? `<${p.dest}>` : `[<${p.dest}>]`));
|
|
42
|
+
const head = spec.usage || `用法:${spec.prog} ${parts.concat(pos).join(" ")}`;
|
|
43
|
+
return head;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function helpText(spec) {
|
|
47
|
+
const lines = [usageText(spec)];
|
|
48
|
+
if (spec.description) lines.push("", spec.description);
|
|
49
|
+
lines.push("", "选项:");
|
|
50
|
+
for (const o of spec.options) {
|
|
51
|
+
const left = `${optionNames(o).join(", ")}${valueLabel(o)}`;
|
|
52
|
+
lines.push(` ${left.padEnd(26)}${o.help || ""}`);
|
|
53
|
+
}
|
|
54
|
+
for (const p of spec.positionals || []) {
|
|
55
|
+
lines.push(` ${`<${p.dest}>`.padEnd(26)}${p.help || ""}`);
|
|
56
|
+
}
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseIntStrict(s) {
|
|
61
|
+
if (!/^-?\d+$/.test(s)) return NaN;
|
|
62
|
+
return parseInt(s, 10);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 解析 argv,返回 { opts, positionals }。 */
|
|
66
|
+
function parse(spec, argv) {
|
|
67
|
+
const opts = {};
|
|
68
|
+
const byToken = new Map();
|
|
69
|
+
for (const o of spec.options) {
|
|
70
|
+
if (o.default !== undefined) opts[o.dest] = o.default;
|
|
71
|
+
for (const t of optionNames(o)) byToken.set(t, o);
|
|
72
|
+
}
|
|
73
|
+
const repeatable = new Set();
|
|
74
|
+
const positionals = [];
|
|
75
|
+
let noMoreOptions = false;
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const t = argv[i];
|
|
79
|
+
if (!noMoreOptions && (t === "-h" || t === "--help")) {
|
|
80
|
+
console.log(helpText(spec));
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
if (!noMoreOptions && t === "--") {
|
|
84
|
+
noMoreOptions = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const looksLikeOption = !noMoreOptions && t.startsWith("-") && t !== "-" && !/^-?\d+$/.test(t);
|
|
88
|
+
if (looksLikeOption) {
|
|
89
|
+
let token = t;
|
|
90
|
+
let inlineVal;
|
|
91
|
+
const eq = t.indexOf("=");
|
|
92
|
+
if (eq > 0 && t.startsWith("--")) {
|
|
93
|
+
token = t.slice(0, eq);
|
|
94
|
+
inlineVal = t.slice(eq + 1);
|
|
95
|
+
}
|
|
96
|
+
const o = byToken.get(token);
|
|
97
|
+
if (!o) die(spec, `错误:未知选项 ${t}`);
|
|
98
|
+
if (!o.arg) {
|
|
99
|
+
if (inlineVal !== undefined) die(spec, `错误:${token} 不接受值`);
|
|
100
|
+
opts[o.dest] = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let val = inlineVal;
|
|
104
|
+
if (val === undefined) {
|
|
105
|
+
i += 1;
|
|
106
|
+
if (i >= argv.length) die(spec, `错误:${token} 需要一个参数`);
|
|
107
|
+
val = argv[i];
|
|
108
|
+
}
|
|
109
|
+
if (o.arg === "int") {
|
|
110
|
+
const n = parseIntStrict(val);
|
|
111
|
+
if (Number.isNaN(n)) die(spec, `错误:${token} 需要整数,收到 ${val}`);
|
|
112
|
+
val = n;
|
|
113
|
+
}
|
|
114
|
+
if (o.choices && !o.choices.includes(val)) {
|
|
115
|
+
die(spec, `错误:${token} 的取值应为 ${o.choices.join(" / ")},收到 ${val}`);
|
|
116
|
+
}
|
|
117
|
+
if (o.repeat) {
|
|
118
|
+
if (!repeatable.has(o.dest)) {
|
|
119
|
+
repeatable.add(o.dest);
|
|
120
|
+
opts[o.dest] = [];
|
|
121
|
+
}
|
|
122
|
+
opts[o.dest].push(val);
|
|
123
|
+
} else {
|
|
124
|
+
opts[o.dest] = val;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
positionals.push(t);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const posSpecs = spec.positionals || [];
|
|
132
|
+
const variadic = posSpecs.some((p) => p.variadic);
|
|
133
|
+
if (!variadic) {
|
|
134
|
+
const maxPos = posSpecs.length;
|
|
135
|
+
if (positionals.length > maxPos && maxPos > 0) {
|
|
136
|
+
die(spec, `错误:多余的位置参数 ${positionals[maxPos]}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let consumed = 0;
|
|
140
|
+
posSpecs.forEach((p, idx) => {
|
|
141
|
+
if (p.variadic) {
|
|
142
|
+
const rest = positionals.slice(idx);
|
|
143
|
+
if (p.required && !rest.length) die(spec, `错误:缺少必需参数 <${p.dest}>`);
|
|
144
|
+
opts[p.dest] = rest;
|
|
145
|
+
consumed = positionals.length;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (p.required && (positionals[idx] === undefined || positionals[idx] === "")) {
|
|
149
|
+
die(spec, `错误:缺少必需参数 <${p.dest}>`);
|
|
150
|
+
}
|
|
151
|
+
opts[p.dest] = positionals[idx];
|
|
152
|
+
consumed = idx + 1;
|
|
153
|
+
});
|
|
154
|
+
if (variadic && consumed < positionals.length) {
|
|
155
|
+
die(spec, `错误:多余的位置参数 ${positionals[consumed]}`);
|
|
156
|
+
}
|
|
157
|
+
for (const o of spec.options) {
|
|
158
|
+
if (o.required && (opts[o.dest] === undefined || opts[o.dest] === null)) {
|
|
159
|
+
die(spec, `错误:缺少必需选项 ${optionNames(o).join(" / ")}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { opts, positionals };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { parse, usageText, helpText };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 内部配置模块(零依赖)。
|
|
4
|
+
* 与 Python 版 os.path.expanduser 对齐:HOME 优先,其次 USERPROFILE——
|
|
5
|
+
* 不能用 os.homedir()(Windows 上它忽略 HOME),否则两边读写不同目录。
|
|
6
|
+
*/
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
function homedir() {
|
|
11
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 配置项名归一化:api-key / api_key / API-KEY 均映射到 JSON key。 */
|
|
15
|
+
function normKey(name, validKeys) {
|
|
16
|
+
const key = String(name).trim().toLowerCase().replace(/-/g, "_");
|
|
17
|
+
return validKeys.includes(key) ? key : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 读取内部配置;文件缺失返回空对象,损坏则警告并忽略(不阻断命令)。 */
|
|
21
|
+
function loadConfig(configPath, validKeys) {
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
24
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const k of Object.keys(data)) if (validKeys.includes(k)) out[k] = data[k];
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
console.error(`警告:配置文件格式异常(应为 JSON 对象),已忽略:${configPath}`);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
if (e && e.code === "ENOENT") return {};
|
|
32
|
+
console.error(`警告:读取配置文件失败,已忽略:${e && e.message}`);
|
|
33
|
+
}
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 原子写入配置(临时文件 + rename),权限收紧到 600(内含敏感值)。 */
|
|
38
|
+
function saveConfig(configPath, cfg) {
|
|
39
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
40
|
+
const tmp = configPath + ".tmp";
|
|
41
|
+
const ordered = {};
|
|
42
|
+
for (const k of Object.keys(cfg).sort()) ordered[k] = cfg[k];
|
|
43
|
+
// 与 Python 版形态一致:sort_keys + 2 空格缩进 + 尾换行 + 非 ASCII 不转义
|
|
44
|
+
fs.writeFileSync(tmp, JSON.stringify(ordered, null, 2) + "\n");
|
|
45
|
+
try {
|
|
46
|
+
fs.chmodSync(tmp, 0o600); // Windows 上意义有限,失败不阻断
|
|
47
|
+
} catch (_) { /* 忽略 */ }
|
|
48
|
+
fs.renameSync(tmp, configPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 敏感值脱敏显示:保留首尾各 4 位,中间打码。 */
|
|
52
|
+
function maskSecret(value) {
|
|
53
|
+
const s = String(value);
|
|
54
|
+
if (s.length <= 8) return "*".repeat(s.length);
|
|
55
|
+
return `${s.slice(0, 4)}…${s.slice(-4)}(${s.length} 位)`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function displayValue(secret, value) {
|
|
59
|
+
return secret ? maskSecret(value) : String(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 单项配置四层解析(命令行 > 内部配置 > 环境变量 > 默认),返回 [生效值, 来源]。 */
|
|
63
|
+
function resolveValue(cliVal, cfgVal, envName, defaultVal) {
|
|
64
|
+
if (cliVal !== undefined && cliVal !== null) return [cliVal, "命令行参数"];
|
|
65
|
+
if (cfgVal !== undefined && cfgVal !== null && cfgVal !== "") return [cfgVal, "内部配置"];
|
|
66
|
+
const envVal = envName ? process.env[envName] : undefined;
|
|
67
|
+
if (envVal) return [envVal, `环境变量 ${envName}`];
|
|
68
|
+
return [defaultVal, "内置默认值"];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- 终端对齐辅助:CJK 等全角字符按 2 列计 ----
|
|
72
|
+
function dispWidth(text) {
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (const ch of text) n += ch.codePointAt(0) > 0x2e7f ? 2 : 1;
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dispPad(text, width) {
|
|
79
|
+
return text + " ".repeat(Math.max(width - dispWidth(text), 1));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 列出各配置项当前生效值与来源(set 不带参数时)。
|
|
84
|
+
* rows: [{ label, key, env, default, secret, legacyFn?, legacyLabel? }]
|
|
85
|
+
*/
|
|
86
|
+
function showConfig(rows, configPath, labelWidth) {
|
|
87
|
+
const w = labelWidth || 10;
|
|
88
|
+
console.log(`配置文件:${configPath}\n`);
|
|
89
|
+
console.log(dispPad("项", w) + dispPad("当前生效值", 46) + "来源");
|
|
90
|
+
for (const r of rows) {
|
|
91
|
+
const cfgVal = r.cfg ? r.cfg[r.key] : undefined;
|
|
92
|
+
let value;
|
|
93
|
+
let source;
|
|
94
|
+
if (cfgVal !== undefined && cfgVal !== null && cfgVal !== "") {
|
|
95
|
+
value = String(cfgVal);
|
|
96
|
+
source = "内部配置";
|
|
97
|
+
} else if (r.legacyFn && r.legacyFn()) {
|
|
98
|
+
value = r.legacyFn();
|
|
99
|
+
source = r.legacyLabel;
|
|
100
|
+
} else if (r.env && process.env[r.env]) {
|
|
101
|
+
value = process.env[r.env];
|
|
102
|
+
source = `环境变量 ${r.env}`;
|
|
103
|
+
} else {
|
|
104
|
+
value = r.default !== undefined && r.default !== null ? String(r.default) : "(未设置)";
|
|
105
|
+
source = r.default !== undefined && r.default !== null ? "内置默认值" : "—";
|
|
106
|
+
}
|
|
107
|
+
if (r.secret && value !== "(未设置)") value = maskSecret(value);
|
|
108
|
+
// 内部配置覆盖了环境变量时提示被忽略的值,便于排查「环境变量为何不生效」
|
|
109
|
+
if (source === "内部配置" && r.env && process.env[r.env]
|
|
110
|
+
&& process.env[r.env] !== String(cfgVal)) {
|
|
111
|
+
source += `(环境变量 ${r.env} 被忽略)`;
|
|
112
|
+
}
|
|
113
|
+
console.log(dispPad(r.label, w) + dispPad(value, 46) + source);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
homedir,
|
|
119
|
+
normKey,
|
|
120
|
+
loadConfig,
|
|
121
|
+
saveConfig,
|
|
122
|
+
maskSecret,
|
|
123
|
+
displayValue,
|
|
124
|
+
resolveValue,
|
|
125
|
+
dispWidth,
|
|
126
|
+
dispPad,
|
|
127
|
+
showConfig,
|
|
128
|
+
};
|
package/lib/httputil.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP 辅助(零依赖):直连 / http 代理(CONNECT 隧道或纯 http 转发),
|
|
4
|
+
* 整包读响应,把非 2xx 也原样返回——重试与错误语义由调用方分层决定。
|
|
5
|
+
*/
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const https = require("https");
|
|
8
|
+
const net = require("net");
|
|
9
|
+
const tls = require("tls");
|
|
10
|
+
const { URL } = require("url");
|
|
11
|
+
|
|
12
|
+
class HttpError extends Error {
|
|
13
|
+
constructor(status, detail, url) {
|
|
14
|
+
super(`HTTP ${status}${detail ? `:${detail}` : ""}${url ? `(${url})` : ""}`);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.detail = detail || "";
|
|
17
|
+
this.url = url || "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class TimeoutError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("请求超时");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withTimeout(promise, timeoutMs, makeError) {
|
|
28
|
+
let timer;
|
|
29
|
+
return Promise.race([
|
|
30
|
+
promise,
|
|
31
|
+
new Promise((_, reject) => {
|
|
32
|
+
timer = setTimeout(() => reject(makeError()), timeoutMs);
|
|
33
|
+
}),
|
|
34
|
+
]).finally(() => clearTimeout(timer));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** TCP 连到代理并完成 CONNECT 隧道握手,返回裸 socket。 */
|
|
38
|
+
function connectViaProxy(proxyUrl, targetUrl, timeoutMs) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const sock = net.connect({
|
|
41
|
+
host: proxyUrl.hostname,
|
|
42
|
+
port: Number(proxyUrl.port || 80),
|
|
43
|
+
});
|
|
44
|
+
const fail = (err) => {
|
|
45
|
+
sock.destroy();
|
|
46
|
+
reject(err);
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(() => fail(new TimeoutError()), timeoutMs);
|
|
49
|
+
sock.on("error", (e) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
fail(new Error(`代理连接失败:${e.message}`));
|
|
52
|
+
});
|
|
53
|
+
sock.on("connect", () => {
|
|
54
|
+
sock.write(
|
|
55
|
+
`CONNECT ${targetUrl.hostname}:${targetUrl.port || 443} HTTP/1.1\r\n`
|
|
56
|
+
+ `Host: ${targetUrl.hostname}:${targetUrl.port || 443}\r\n\r\n`);
|
|
57
|
+
});
|
|
58
|
+
let buf = "";
|
|
59
|
+
sock.on("data", function onData(d) {
|
|
60
|
+
buf += d.toString("latin1");
|
|
61
|
+
if (!buf.includes("\r\n\r\n")) return;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
sock.removeListener("data", onData);
|
|
64
|
+
const line = buf.split("\r\n")[0];
|
|
65
|
+
if (!/^HTTP\/1\.[01] 200/.test(line)) {
|
|
66
|
+
sock.destroy();
|
|
67
|
+
reject(new Error(`代理 CONNECT 失败:${line}`));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
resolve(sock);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 在既有裸 socket 上做 TLS 握手(SNI 用目标主机名)。 */
|
|
76
|
+
function tlsWrap(sock, hostname, timeoutMs) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const tlsSock = tls.connect({ socket: sock, servername: hostname });
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
tlsSock.destroy();
|
|
81
|
+
reject(new TimeoutError());
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
tlsSock.once("secureConnect", () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
resolve(tlsSock);
|
|
86
|
+
});
|
|
87
|
+
tlsSock.once("error", (e) => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
reject(new Error(`TLS 握手失败:${e.message}`));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 单次 HTTP 请求。
|
|
96
|
+
* opts: { urlStr, method, headers, body(Buffer|string|null), proxy, timeoutSec }
|
|
97
|
+
* 返回 { status, headers, body:Buffer };网络错误/超时抛异常(含 TimeoutError)。
|
|
98
|
+
*/
|
|
99
|
+
function requestOnce(opts) {
|
|
100
|
+
const { urlStr, method = "GET", headers = {}, body = null,
|
|
101
|
+
proxy, timeoutSec = 120 } = opts;
|
|
102
|
+
const u = new URL(urlStr);
|
|
103
|
+
const isHttps = u.protocol === "https:";
|
|
104
|
+
if (!isHttps && u.protocol !== "http:") {
|
|
105
|
+
return Promise.reject(new Error(`不支持的协议:${u.protocol}`));
|
|
106
|
+
}
|
|
107
|
+
const timeoutMs = Math.max(1, Number(timeoutSec)) * 1000;
|
|
108
|
+
|
|
109
|
+
const sendHeaders = { ...headers };
|
|
110
|
+
if (body !== null && body !== undefined
|
|
111
|
+
&& !Object.keys(sendHeaders).some((k) => k.toLowerCase() === "content-length")) {
|
|
112
|
+
sendHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const collect = (req) => new Promise((resolve, reject) => {
|
|
116
|
+
req.setTimeout(timeoutMs, () => req.destroy(new TimeoutError()));
|
|
117
|
+
req.on("error", reject);
|
|
118
|
+
req.on("response", (res) => {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
res.on("data", (c) => chunks.push(c));
|
|
121
|
+
res.on("end", () => {
|
|
122
|
+
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) });
|
|
123
|
+
});
|
|
124
|
+
res.on("error", reject);
|
|
125
|
+
});
|
|
126
|
+
if (body !== null && body !== undefined) req.write(body);
|
|
127
|
+
req.end();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!proxy) {
|
|
131
|
+
// 直连:交给 http/https 模块自己建连
|
|
132
|
+
const lib = isHttps ? https : http;
|
|
133
|
+
const req = lib.request(urlStr, {
|
|
134
|
+
method,
|
|
135
|
+
headers: sendHeaders,
|
|
136
|
+
agent: false,
|
|
137
|
+
});
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
req.setTimeout(timeoutMs, () => req.destroy(new TimeoutError()));
|
|
140
|
+
req.on("error", reject);
|
|
141
|
+
req.on("response", (res) => {
|
|
142
|
+
const chunks = [];
|
|
143
|
+
res.on("data", (c) => chunks.push(c));
|
|
144
|
+
res.on("end", () => {
|
|
145
|
+
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) });
|
|
146
|
+
});
|
|
147
|
+
res.on("error", reject);
|
|
148
|
+
});
|
|
149
|
+
if (body !== null && body !== undefined) req.write(body);
|
|
150
|
+
req.end();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const p = new URL(proxy);
|
|
155
|
+
if (p.protocol !== "http:") {
|
|
156
|
+
return Promise.reject(new Error(`仅支持 http:// 代理:${proxy}`));
|
|
157
|
+
}
|
|
158
|
+
if (!isHttps) {
|
|
159
|
+
// 纯 http 目标经 http 代理:向代理发绝对 URI(RFC 7230 §5.3.2)
|
|
160
|
+
const req = http.request({
|
|
161
|
+
host: p.hostname,
|
|
162
|
+
port: Number(p.port || 80),
|
|
163
|
+
path: urlStr,
|
|
164
|
+
method,
|
|
165
|
+
headers: sendHeaders,
|
|
166
|
+
agent: false,
|
|
167
|
+
createConnection: () => net.connect({ host: p.hostname, port: Number(p.port || 80) }),
|
|
168
|
+
});
|
|
169
|
+
return collect(req);
|
|
170
|
+
}
|
|
171
|
+
// https 目标经 http 代理:CONNECT 隧道 + TLS
|
|
172
|
+
return (async () => {
|
|
173
|
+
const raw = await withTimeout(
|
|
174
|
+
connectViaProxy(p, u, timeoutMs), timeoutMs, () => new TimeoutError());
|
|
175
|
+
const tlsSock = await withTimeout(
|
|
176
|
+
tlsWrap(raw, u.hostname, timeoutMs), timeoutMs, () => new TimeoutError());
|
|
177
|
+
const req = https.request({
|
|
178
|
+
host: u.hostname,
|
|
179
|
+
port: Number(u.port || 443),
|
|
180
|
+
path: u.pathname + u.search,
|
|
181
|
+
method,
|
|
182
|
+
headers: sendHeaders,
|
|
183
|
+
agent: false,
|
|
184
|
+
servername: u.hostname,
|
|
185
|
+
createConnection: () => tlsSock,
|
|
186
|
+
});
|
|
187
|
+
return collect(req);
|
|
188
|
+
})();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** GET 下载,跟随 301/302/303/307/308(上限 maxRedirects)。 */
|
|
192
|
+
async function download(urlStr, { proxy, timeoutSec = 120, headers = {}, maxRedirects = 5 } = {}) {
|
|
193
|
+
let current = urlStr;
|
|
194
|
+
for (let i = 0; i <= maxRedirects; i++) {
|
|
195
|
+
const res = await requestOnce({ urlStr: current, method: "GET", headers, proxy, timeoutSec });
|
|
196
|
+
if ([301, 302, 303, 307, 308].includes(res.status) && res.headers.location) {
|
|
197
|
+
current = new URL(res.headers.location, current).href;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (res.status !== 200) {
|
|
201
|
+
throw new HttpError(res.status, "", current);
|
|
202
|
+
}
|
|
203
|
+
return res.body;
|
|
204
|
+
}
|
|
205
|
+
throw new Error("重定向次数过多");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { requestOnce, download, HttpError, TimeoutError };
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@cc123hh/gitee-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@cc123hh/gitee-cli",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Gitee 仓库管理命令行工具——纯 Node 零依赖实现",
|
|
5
|
+
"bin": {
|
|
6
|
+
"gitee": "bin/gitee.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"lib/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"gitee",
|
|
16
|
+
"cli",
|
|
17
|
+
"git",
|
|
18
|
+
"repository"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=16"
|
|
23
|
+
}
|
|
24
|
+
}
|