-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathupload-github-plugin.js
More file actions
284 lines (266 loc) · 12.5 KB
/
Copy pathupload-github-plugin.js
File metadata and controls
284 lines (266 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// upload-github-plugin.js — 本地 DSH 插件 → GitHub 上传器
//
// 背景:本机 schannel TLS 损坏且 hosts 拦截 github.com/api.github.com,
// 故全程用 Node OpenSSL + 自定义 lookup 钉住真实 IP(经国内 DoH doh.pub/alidns 解析,
// 失败则用内置已知 IP 兜底),不依赖系统 DNS/schannel/git。
//
// 认证:GitHub PAT 经 --token-file 或环境变量 GH_PLUGIN_TOKEN 传入(不进命令行)。
// 上传走 Contents API:GET contents 取 sha(存在时) → PUT contents(base64)。
// 仓库不存在时经 POST /user/repos {auto_init:true} 自动创建(默认开启,可 --no-create)。
//
// 用法:
// node upload-github-plugin.js --repo "owner/repo" --plugin-dir "<dir>" --token-file "<f>"
// node upload-github-plugin.js --repo "owner/repo" --plugin-dir "<dir>" --check # 免 token 只查仓库
// --files "a.js,b/c.md" 显式文件列表;--branch <b>;--message <m>;--private;--no-create
//
// 输出:人类可读行 + 最后一行 __UPLOAD_JSON__{...};成功 exit 0。
'use strict';
const https = require('node:https');
const fs = require('node:fs');
const path = require('node:path');
// ---------- 参数解析 ----------
function parseArgs(argv) {
const a = { files: null, branch: '', message: '', private: false, create: true, check: false, repo: '', pluginDir: '', tokenFile: '' };
for (let i = 0; i < argv.length; i++) {
const v = argv[i];
const next = () => (i + 1 < argv.length ? argv[++i] : '');
if (v === '--repo') a.repo = next();
else if (v === '--plugin-dir') a.pluginDir = next();
else if (v === '--files') a.files = next().split(',').map(s => s.trim()).filter(Boolean);
else if (v === '--branch') a.branch = next();
else if (v === '--message') a.message = next();
else if (v === '--token-file') a.tokenFile = next();
else if (v === '--private') a.private = true;
else if (v === '--no-create') a.create = false;
else if (v === '--check') a.check = true;
}
const m = String(a.repo).trim().replace(/^https?:\/\/github\.com\//i, '').replace(/\.git$/, '').replace(/^\/+|\/+$/g, '').match(/^([^/]+)\/([^/]+)$/);
if (!m) throw new Error('--repo 必须是 owner/repo 形式(或 GitHub URL)');
a.owner = m[1];
a.repoName = m[2];
if (!a.pluginDir) throw new Error('--plugin-dir 必填');
return a;
}
// ---------- DoH / IP 解析 ----------
const KNOWN_IPS = ['140.82.112.5', '140.82.112.6', '140.82.113.5', '140.82.113.6', '140.82.114.5', '140.82.114.6', '140.82.116.5', '140.82.116.6', '20.205.243.168'];
async function dohResolve(host) {
const endpoints = [
['doh.pub', 'https://doh.pub/dns-query?name=' + host + '&type=A'],
['alidns', 'https://223.5.5.5/resolve?name=' + host + '&type=A'],
['cloudflare', 'https://cloudflare-dns.com/dns-query?name=' + host + '&type=A'],
['dns.google', 'https://dns.google/resolve?name=' + host + '&type=A'],
];
for (const [name, url] of endpoints) {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 6000);
const res = await fetch(url, { headers: { accept: 'application/dns-json' }, signal: ctrl.signal });
clearTimeout(timer);
if (!res.ok) continue;
const j = await res.json();
const ips = (j.Answer || []).filter(a => a.type === 1).map(a => a.data);
if (ips.length) return { source: name, ips };
} catch (e) { /* try next endpoint */ }
}
return { source: 'builtin', ips: KNOWN_IPS };
}
// ---------- 带 IP 钉扎的 GitHub API 请求 ----------
function apiRequest(method, urlPath, opts) {
opts = opts || {};
const ips = opts.ips || KNOWN_IPS;
const attempts = Math.max(ips.length, 1);
return new Promise((resolve, reject) => {
let tried = 0;
const tryNext = (lastErr) => {
if (tried >= attempts) { reject(lastErr || new Error('all API IPs failed')); return; }
const ip = ips[tried % ips.length];
tried++;
const bodyBuf = opts.body ? Buffer.from(JSON.stringify(opts.body), 'utf8') : null;
const headers = Object.assign({
'User-Agent': 'dsh-plugin-uploader',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
}, opts.token ? { Authorization: 'Bearer ' + opts.token } : {});
if (bodyBuf) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = bodyBuf.length; }
const req = https.request({
host: 'api.github.com',
path: urlPath,
method,
family: 4,
headers,
lookup: (hostname, options, callback) => callback(null, ip, 4),
}, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let parsed = null;
try { parsed = JSON.parse(text); } catch (e) { parsed = null; }
resolve({ status: res.statusCode, body: parsed, text });
});
});
req.setTimeout(15000, () => req.destroy(new Error('timeout')));
req.on('error', (e) => tryNext(e));
if (bodyBuf) req.write(bodyBuf);
req.end();
};
tryNext(null);
});
}
function encPath(relPath) {
return String(relPath).replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/');
}
// ---------- 文件收集 ----------
const TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.md', '.txt', '.yml', '.yaml', '.ps1', '.css', '.html', '.toml', '.ini', '.ts']);
const SKIP_DIRS = new Set(['.git', 'node_modules', '.dsh-trash', '__pycache__']);
function collectFiles(pluginDir, explicitFiles) {
const root = path.resolve(pluginDir);
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error('plugin-dir 不存在: ' + pluginDir);
const relPaths = [];
if (explicitFiles && explicitFiles.length) {
for (const f of explicitFiles) {
const abs = path.resolve(root, f);
if (!abs.startsWith(root + path.sep) && abs !== root) throw new Error('文件越出 plugin-dir: ' + f);
if (!fs.existsSync(abs)) throw new Error('文件不存在: ' + f);
relPaths.push(path.relative(root, abs));
}
} else {
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.git') || SKIP_DIRS.has(entry.name)) continue;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(abs); continue; }
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!TEXT_EXT.has(ext)) continue;
if (fs.statSync(abs).size > 1024 * 1024) continue;
if (relPaths.length >= 100) continue;
relPaths.push(path.relative(root, abs));
}
};
walk(root);
}
if (relPaths.length === 0) throw new Error('plugin-dir 内没有可上传的文本文件');
relPaths.sort();
return { root, relPaths };
}
// ---------- 主流程 ----------
async function main() {
const a = parseArgs(process.argv.slice(2));
const token = a.tokenFile && fs.existsSync(a.tokenFile)
? fs.readFileSync(a.tokenFile, 'utf8').trim()
: (process.env.GH_PLUGIN_TOKEN || '').trim();
if (!a.check && !token) throw new Error('未提供 GitHub token(--token-file 或环境变量 GH_PLUGIN_TOKEN)');
const result = { ok: false, owner: a.owner, repo: a.repoName, branch: '', created_repo: false, uploaded: [], files: [], errors: [], dns: '', check: a.check };
const resolved = await dohResolve('api.github.com');
const ips = resolved.ips;
result.dns = resolved.source;
// 1. 查仓库
let repoInfo = null;
try {
const r = await apiRequest('GET', '/repos/' + encPath(a.owner) + '/' + encPath(a.repoName), { ips, token: a.check ? '' : token });
if (r.status === 200 && r.body) {
repoInfo = r.body;
result.repo_status = 'existing';
} else if (r.status === 404) {
result.repo_status = 'missing';
} else {
throw new Error('查询仓库失败 HTTP ' + r.status + ': ' + (r.body && r.body.message || r.text.slice(0, 200)));
}
} catch (e) { throw new Error('查询仓库失败: ' + e.message); }
// 2. 仓库不存在 → 创建(check 模式只报告)
if (!repoInfo) {
if (a.check) {
result.repo_status = 'missing';
result.branch = a.branch || 'main';
result.ok = true;
result.message = 'check OK: ' + result.repo + ' 仓库不存在(上传时会自动创建)';
return result;
}
if (!a.create) throw new Error('仓库 ' + a.owner + '/' + a.repoName + ' 不存在(未开启自动创建)');
const c = await apiRequest('POST', '/user/repos', { ips, token, body: { name: a.repoName, private: a.private, auto_init: true } });
if (c.status === 401) throw new Error('token 无效或已过期(401)');
if (c.status === 422 && c.body && /name already exists|Repository creation failed/i.test(String(c.body.message || ''))) {
repoInfo = await (await apiRequest('GET', '/repos/' + encPath(a.owner) + '/' + encPath(a.repoName), { ips, token })).body;
result.repo_status = 'existing';
} else if (c.status !== 201) {
throw new Error('创建仓库失败 HTTP ' + c.status + ': ' + (c.body && c.body.message || c.text.slice(0, 200)));
} else {
repoInfo = c.body;
result.repo_status = 'created';
result.created_repo = true;
}
}
const defaultBranch = (repoInfo && repoInfo.default_branch) || 'main';
const branch = a.branch || defaultBranch;
result.branch = branch;
if (a.check) {
result.ok = true;
result.message = 'check OK: ' + result.repo + ' [' + branch + '] (dns via ' + result.dns + ')';
return result;
}
const { root, relPaths } = collectFiles(a.pluginDir, a.files);
result.files = relPaths;
// 4. 逐个上传(Contents API;内容一致自动跳过,避免空提交)
const message = a.message || 'Update plugin via dsh uploader';
for (const rel of relPaths) {
const abs = path.join(root, rel);
const localText = fs.readFileSync(abs, 'utf8');
const content = Buffer.from(localText, 'utf8').toString('base64');
const apiPath = '/repos/' + encPath(a.owner) + '/' + encPath(a.repoName) + '/contents/' + encPath(rel);
let sha = '';
let unchanged = false;
try {
const g = await apiRequest('GET', apiPath + '?ref=' + encodeURIComponent(branch), { ips, token });
if (g.status === 200 && g.body && g.body.sha) {
sha = g.body.sha;
const remoteB64 = typeof g.body.content === 'string' ? g.body.content.replace(/\r?\n/g, '') : '';
if (remoteB64) {
try {
const remoteText = Buffer.from(remoteB64, 'base64').toString('utf8');
if (remoteText.replace(/\r\n/g, '\n') === localText.replace(/\r\n/g, '\n')) unchanged = true;
} catch (e) { /* 内容解码失败按需更新处理 */ }
}
}
else if (g.status === 401) throw new Error('token 无效或已过期(401)');
} catch (e) {
if (/token 无效/.test(e.message)) throw e;
/* GET 失败按新建处理 */
}
if (unchanged) {
result.uploaded.push({ path: rel, status: 'unchanged', sha });
continue;
}
const body = { message, content, branch };
if (sha) body.sha = sha;
const p = await apiRequest('PUT', apiPath, { ips, token, body });
if (p.status === 201 || p.status === 200) {
result.uploaded.push({ path: rel, status: p.status === 201 ? 'created' : 'updated', sha: (p.body && p.body.content && p.body.content.sha) || '' });
} else if (p.status === 401) {
throw new Error('token 无效或已过期(401)');
} else if (p.status === 403 && p.body && /rate limit/i.test(String(p.body.message || ''))) {
throw new Error('API 速率限制(403 rate limit),稍后再试');
} else {
throw new Error('上传失败 ' + rel + ': HTTP ' + p.status + ' ' + (p.body && p.body.message || p.text.slice(0, 200)));
}
}
result.ok = true;
result.message = 'uploaded ' + result.uploaded.length + ' file(s) to ' + result.repo + ' [' + branch + ']';
return result;
}
main().then((r) => {
console.log('== 上传结果 ==');
console.log('仓库: ' + r.owner + '/' + r.repo + (r.created_repo ? '(新建)' : '') + ' 分支: ' + r.branch + ' DNS: ' + r.dns);
if (r.check) { console.log(r.message); }
else {
for (const f of r.uploaded) console.log(' ' + f.status + ' ' + f.path);
console.log('成功上传 ' + r.uploaded.length + ' 个文件');
}
console.log('__UPLOAD_JSON__' + JSON.stringify(r));
process.exit(0);
}).catch((e) => {
const r = { ok: false, errors: [String(e && e.message ? e.message : e)] };
console.log('上传失败: ' + r.errors[0]);
console.log('__UPLOAD_JSON__' + JSON.stringify(r));
process.exit(1);
});