@cc123hh/gitee-cli 0.0.0-stage → 0.4.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/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
+ };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@cc123hh/gitee-cli",
3
+ "version": "0.4.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
+ }