dsh-git-workspace 0.0.0-stage → 0.1.1
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 +28 -0
- package/README.md +217 -2
- package/cordis.patch.yml +9 -0
- package/lib/client.js +736 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +815 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/package.json +71 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dsh-external/dsh-git-workspace — host 半。
|
|
3
|
+
*
|
|
4
|
+
* 给浏览器半提供本地回环 HTTP 路由,把指定 GitHub 仓库拉到磁盘并回报绝对路径。
|
|
5
|
+
* 工作区的注册由 ui-workspace 的洞所有者完成,本插件不碰工作区注册表。
|
|
6
|
+
*
|
|
7
|
+
* 路由:
|
|
8
|
+
* POST /gh-workspace/clone { repo, branch?, parentDir?, token? } -> { jobId }
|
|
9
|
+
* GET /gh-workspace/job?id=xxx -> { state, percent, detail, path?, error? }
|
|
10
|
+
* POST /gh-workspace/job/cancel { id } -> { cancelled }
|
|
11
|
+
* GET /gh-workspace/jobs -> 进行中的任务列表(供角落浮层恢复)
|
|
12
|
+
*
|
|
13
|
+
* 克隆是后台任务:请求立即返回 jobId,进度通过轮询读取,不阻塞调用方。
|
|
14
|
+
* 凭据走 GIT_CONFIG_* 环境变量注入,不进入命令行参数(避免 ps 泄露)。
|
|
15
|
+
*
|
|
16
|
+
* 拉取走两级:优先 git clone(本机 git 不在 PATH,必须按候选路径探测),
|
|
17
|
+
* git 不可用或失败时退到 codeload tarball + 系统 tar。tarball 结果没有 .git。
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { homedir, tmpdir } from 'node:os';
|
|
23
|
+
import { isAbsolute, join } from 'node:path';
|
|
24
|
+
export const name = 'dsh-git-workspace';
|
|
25
|
+
export const inject = ['webServer'];
|
|
26
|
+
const ROUTE_PREFIX = '/gh-workspace';
|
|
27
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
28
|
+
const DEFAULT_CLONE_TIMEOUT_MS = 900_000;
|
|
29
|
+
const MAX_TARBALL_BYTES = 256 * 1024 * 1024;
|
|
30
|
+
/** 同时进行的克隆任务上限:每个任务都会下载上百 MB。 */
|
|
31
|
+
const MAX_CONCURRENT_JOBS = 3;
|
|
32
|
+
/** 完成的任务保留多久(毫秒),够客户端取一次结果即可。 */
|
|
33
|
+
const JOB_RETENTION_MS = 10 * 60 * 1000;
|
|
34
|
+
const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
35
|
+
const BRANCH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
36
|
+
const HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d{1,5})?$/;
|
|
37
|
+
const REPO_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
38
|
+
const PATH_CHARS_RE = /^[A-Za-z0-9._/-]+$/;
|
|
39
|
+
function lastSegment(path) {
|
|
40
|
+
const parts = path.split('/').filter((part) => part !== '');
|
|
41
|
+
const last = parts[parts.length - 1];
|
|
42
|
+
if (last === undefined)
|
|
43
|
+
return null;
|
|
44
|
+
const name = last.endsWith('.git') ? last.slice(0, -4) : last;
|
|
45
|
+
if (name === '' || name === '.' || name === '..')
|
|
46
|
+
return null;
|
|
47
|
+
if (!REPO_NAME_RE.test(name))
|
|
48
|
+
return null;
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 解析任意 Git 仓库地址:
|
|
53
|
+
* - 短格式 `owner/repo`(默认 github.com,保持旧行为)
|
|
54
|
+
* - 协议地址 `https://host/path`、`ssh://git@host/path`、`git://host/path`
|
|
55
|
+
* - SCP 风格 `git@host:path`
|
|
56
|
+
*
|
|
57
|
+
* 明确拒绝本地路径与 file://:这不是「clone 本地目录」的入口,
|
|
58
|
+
* 放开等于把读取本地文件系统的能力暴露给调用方。
|
|
59
|
+
*/
|
|
60
|
+
function parseRepo(input) {
|
|
61
|
+
if (typeof input !== 'string')
|
|
62
|
+
return null;
|
|
63
|
+
const raw = input.trim();
|
|
64
|
+
if (raw === '')
|
|
65
|
+
return null;
|
|
66
|
+
// 本地路径 / file:// 一律拒绝
|
|
67
|
+
if (/^file:/i.test(raw))
|
|
68
|
+
return null;
|
|
69
|
+
if (raw.startsWith('/') || raw.startsWith('\\') || raw.startsWith('.') || raw.startsWith('~'))
|
|
70
|
+
return null;
|
|
71
|
+
if (/^[A-Za-z]:[\\/]/.test(raw))
|
|
72
|
+
return null;
|
|
73
|
+
// 1) 协议地址
|
|
74
|
+
const withScheme = /^(https?|ssh|git):\/\/([^/]+)\/(.+)$/.exec(raw);
|
|
75
|
+
if (withScheme !== null) {
|
|
76
|
+
const scheme = withScheme[1].toLowerCase();
|
|
77
|
+
const authority = withScheme[2];
|
|
78
|
+
const at = authority.lastIndexOf('@');
|
|
79
|
+
const hostPort = at >= 0 ? authority.slice(at + 1) : authority;
|
|
80
|
+
const path = withScheme[3].replace(/\/+$/, '');
|
|
81
|
+
if (!HOST_RE.test(hostPort))
|
|
82
|
+
return null;
|
|
83
|
+
if (!PATH_CHARS_RE.test(path) || path.includes('..'))
|
|
84
|
+
return null;
|
|
85
|
+
const repo = lastSegment(path);
|
|
86
|
+
if (repo === null)
|
|
87
|
+
return null;
|
|
88
|
+
const host = hostPort.split(':')[0].toLowerCase();
|
|
89
|
+
const parts = path.split('/').filter((part) => part !== '');
|
|
90
|
+
return {
|
|
91
|
+
label: `${host}/${parts.join('/')}`,
|
|
92
|
+
owner: host === 'github.com' && parts.length >= 2 ? parts[0] : '',
|
|
93
|
+
repo,
|
|
94
|
+
url: scheme === 'http' ? raw : raw,
|
|
95
|
+
host,
|
|
96
|
+
isGitHub: host === 'github.com',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// 2) SCP 风格 user@host:path
|
|
100
|
+
const scp = /^([A-Za-z0-9_.-]+)@([A-Za-z0-9.-]+):(.+)$/.exec(raw);
|
|
101
|
+
if (scp !== null) {
|
|
102
|
+
const host = scp[2].toLowerCase();
|
|
103
|
+
const path = scp[3].replace(/\/+$/, '');
|
|
104
|
+
if (!PATH_CHARS_RE.test(path) || path.includes('..'))
|
|
105
|
+
return null;
|
|
106
|
+
const repo = lastSegment(path);
|
|
107
|
+
if (repo === null)
|
|
108
|
+
return null;
|
|
109
|
+
const parts = path.split('/').filter((part) => part !== '');
|
|
110
|
+
return {
|
|
111
|
+
label: `${host}/${parts.join('/')}`,
|
|
112
|
+
owner: host === 'github.com' && parts.length >= 2 ? parts[0] : '',
|
|
113
|
+
repo,
|
|
114
|
+
url: raw,
|
|
115
|
+
host,
|
|
116
|
+
isGitHub: host === 'github.com',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// 3) 短格式 owner/repo → github.com
|
|
120
|
+
const short = /^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9._-]+?)(?:\.git)?$/.exec(raw);
|
|
121
|
+
if (short !== null) {
|
|
122
|
+
const owner = short[1];
|
|
123
|
+
const repo = short[2];
|
|
124
|
+
if (!SEGMENT_RE.test(owner) || !REPO_NAME_RE.test(repo) || repo === '.' || repo === '..')
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
label: `${owner}/${repo}`,
|
|
128
|
+
owner,
|
|
129
|
+
repo,
|
|
130
|
+
url: `https://github.com/${owner}/${repo}.git`,
|
|
131
|
+
host: 'github.com',
|
|
132
|
+
isGitHub: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
function isLoopback(req) {
|
|
138
|
+
const addr = req?.socket?.remoteAddress;
|
|
139
|
+
if (typeof addr !== 'string')
|
|
140
|
+
return false;
|
|
141
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
142
|
+
}
|
|
143
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
144
|
+
function hostnameOf(hostHeader) {
|
|
145
|
+
if (typeof hostHeader !== 'string' || hostHeader === '')
|
|
146
|
+
return '';
|
|
147
|
+
const trimmed = hostHeader.trim();
|
|
148
|
+
if (trimmed.startsWith('[')) {
|
|
149
|
+
const end = trimmed.indexOf(']');
|
|
150
|
+
return end > 0 ? trimmed.slice(0, end + 1) : trimmed;
|
|
151
|
+
}
|
|
152
|
+
const colon = trimmed.lastIndexOf(':');
|
|
153
|
+
return colon > 0 ? trimmed.slice(0, colon) : trimmed;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 回环地址只挡「非本机连接」,挡不住本机浏览器里的恶意页面:
|
|
157
|
+
* 任何站点都能向 127.0.0.1:<port> 发一个 text/plain 的跨站 POST,
|
|
158
|
+
* 触发任意目录写入;DNS rebinding 还能把任意域名解析到回环,
|
|
159
|
+
* 让攻击页同源读取 /jobs、/job 的任务与路径信息。
|
|
160
|
+
*
|
|
161
|
+
* 三道防线:
|
|
162
|
+
* 1. Host 必须是回环主机名 —— 挡住 DNS rebinding。
|
|
163
|
+
* 2. POST 必须是 application/json —— 非简单请求,跨站会先发 preflight,
|
|
164
|
+
* 而本服务不响应 preflight,浏览器就不会真正发出请求。
|
|
165
|
+
* 3. Origin 存在时必须是回环源 —— 兜住同源策略覆盖不到的场景。
|
|
166
|
+
*/
|
|
167
|
+
function isTrustedRequest(req) {
|
|
168
|
+
if (!isLoopback(req))
|
|
169
|
+
return false;
|
|
170
|
+
const hostname = hostnameOf(req.headers?.host);
|
|
171
|
+
if (!LOOPBACK_HOSTS.has(hostname.toLowerCase()))
|
|
172
|
+
return false;
|
|
173
|
+
if (req.method === 'POST') {
|
|
174
|
+
const contentType = String(req.headers?.['content-type'] ?? '').toLowerCase();
|
|
175
|
+
if (!contentType.startsWith('application/json'))
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const origin = req.headers?.origin;
|
|
179
|
+
if (typeof origin === 'string' && origin !== '' && origin !== 'null') {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = new URL(origin);
|
|
182
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
function writeJson(res, status, payload) {
|
|
192
|
+
res.statusCode = status;
|
|
193
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
194
|
+
res.setHeader('cache-control', 'no-store');
|
|
195
|
+
res.end(JSON.stringify(payload));
|
|
196
|
+
}
|
|
197
|
+
async function readJsonBody(req) {
|
|
198
|
+
const chunks = [];
|
|
199
|
+
let total = 0;
|
|
200
|
+
for await (const chunk of req) {
|
|
201
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
202
|
+
total += buf.length;
|
|
203
|
+
if (total > MAX_BODY_BYTES)
|
|
204
|
+
throw new Error('请求体过大');
|
|
205
|
+
chunks.push(buf);
|
|
206
|
+
}
|
|
207
|
+
if (total === 0)
|
|
208
|
+
return {};
|
|
209
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
210
|
+
}
|
|
211
|
+
async function fileExists(path) {
|
|
212
|
+
try {
|
|
213
|
+
const info = await stat(path);
|
|
214
|
+
return info.isFile();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function pathExists(path) {
|
|
221
|
+
try {
|
|
222
|
+
await stat(path);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const isWindows = process.platform === 'win32';
|
|
230
|
+
/** 某个裸命令能否执行(--version 探测,5 秒超时)。 */
|
|
231
|
+
function commandWorks(command, args) {
|
|
232
|
+
return new Promise((resolvePromise) => {
|
|
233
|
+
let settled = false;
|
|
234
|
+
let timer;
|
|
235
|
+
const finish = (ok) => {
|
|
236
|
+
if (settled)
|
|
237
|
+
return;
|
|
238
|
+
settled = true;
|
|
239
|
+
if (timer !== undefined)
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
resolvePromise(ok);
|
|
242
|
+
};
|
|
243
|
+
let child;
|
|
244
|
+
try {
|
|
245
|
+
child = spawn(command, args, { stdio: 'ignore', windowsHide: true });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
resolvePromise(false);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
timer = setTimeout(() => {
|
|
252
|
+
try {
|
|
253
|
+
child.kill();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* 已退出 */
|
|
257
|
+
}
|
|
258
|
+
finish(false);
|
|
259
|
+
}, 5000);
|
|
260
|
+
child.on('error', () => finish(false));
|
|
261
|
+
child.on('close', (code) => finish(code === 0));
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* git 可执行文件定位,顺序为:环境变量覆盖 → 各平台常见安装路径 →
|
|
266
|
+
* PATH 里的裸 `git`(Web/Linux 部署通常只在 PATH 里)。
|
|
267
|
+
*/
|
|
268
|
+
async function resolveGitExecutable() {
|
|
269
|
+
const override = process.env.DSH_GIT_EXE;
|
|
270
|
+
if (override && (await fileExists(override)))
|
|
271
|
+
return override;
|
|
272
|
+
const candidates = isWindows
|
|
273
|
+
? [
|
|
274
|
+
'C:\\Program Files\\Git\\cmd\\git.exe',
|
|
275
|
+
'C:\\Program Files\\Git\\bin\\git.exe',
|
|
276
|
+
'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
|
|
277
|
+
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Git', 'cmd', 'git.exe'),
|
|
278
|
+
]
|
|
279
|
+
: ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git'];
|
|
280
|
+
for (const candidate of candidates) {
|
|
281
|
+
if (candidate !== '' && (await fileExists(candidate)))
|
|
282
|
+
return candidate;
|
|
283
|
+
}
|
|
284
|
+
// 回退到 PATH:Web 端(Linux 容器、nix、自编译)常见 git 只在 PATH 里。
|
|
285
|
+
if (await commandWorks('git', ['--version']))
|
|
286
|
+
return 'git';
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
/** 系统 tar:Windows 自带 bsdtar,类 Unix 走 PATH 里的 tar。 */
|
|
290
|
+
function resolveTarExecutable() {
|
|
291
|
+
return isWindows ? 'C:\\Windows\\System32\\tar.exe' : 'tar';
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* git 的凭据相关环境:
|
|
295
|
+
* - GIT_TERMINAL_PROMPT=0 让缺少凭据时立刻失败,而不是等交互输入到超时。
|
|
296
|
+
* - token 走 GIT_CONFIG_* 环境变量而不是命令行参数,这样 token 不会出现在
|
|
297
|
+
* 进程列表里,也不会写进 .git/config。
|
|
298
|
+
*/
|
|
299
|
+
function authEnv(token, host) {
|
|
300
|
+
const env = { GIT_TERMINAL_PROMPT: '0' };
|
|
301
|
+
if (token === '' || host === '')
|
|
302
|
+
return env;
|
|
303
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
304
|
+
return {
|
|
305
|
+
...env,
|
|
306
|
+
GIT_CONFIG_COUNT: '1',
|
|
307
|
+
// 按实际主机匹配,GitHub / GitLab / 自建服务器都能用同一个 token。
|
|
308
|
+
GIT_CONFIG_KEY_0: `http.https://${host}/.extraheader`,
|
|
309
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* 杀整棵进程树。Windows 上 git.exe 会派生 git-remote-https.exe,
|
|
314
|
+
* 只 kill 父进程不会终止子树,stderr 管道继续被占用,close 永不触发,
|
|
315
|
+
* 任务就永远停在 running。
|
|
316
|
+
*/
|
|
317
|
+
function killTree(child) {
|
|
318
|
+
const pid = child.pid;
|
|
319
|
+
if (pid === undefined)
|
|
320
|
+
return;
|
|
321
|
+
if (isWindows) {
|
|
322
|
+
try {
|
|
323
|
+
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* 回退到单进程 kill */
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
child.kill('SIGKILL');
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* 已退出 */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function runProcess(command, args, timeoutMs, options = {}) {
|
|
338
|
+
return new Promise((resolvePromise) => {
|
|
339
|
+
let settled = false;
|
|
340
|
+
let timer;
|
|
341
|
+
let cancelPoll;
|
|
342
|
+
const finish = (ok, stderr, cancelled = false) => {
|
|
343
|
+
if (settled)
|
|
344
|
+
return;
|
|
345
|
+
settled = true;
|
|
346
|
+
if (timer !== undefined)
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
if (cancelPoll !== undefined)
|
|
349
|
+
clearInterval(cancelPoll);
|
|
350
|
+
resolvePromise({ ok, stderr, cancelled });
|
|
351
|
+
};
|
|
352
|
+
let child;
|
|
353
|
+
try {
|
|
354
|
+
child = spawn(command, args, {
|
|
355
|
+
windowsHide: true,
|
|
356
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
357
|
+
env: { ...process.env, ...(options.env ?? {}) },
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
finish(false, error instanceof Error ? error.message : String(error));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
options.onSpawn?.({ kill: () => killTree(child) });
|
|
365
|
+
// 超时与取消都杀整棵树,并各留一个兜底 finish:树杀后 close 仍可能
|
|
366
|
+
// 因为管道被占用而不触发,没有兜底 job 会永远停在 running。
|
|
367
|
+
timer = setTimeout(() => {
|
|
368
|
+
killTree(child);
|
|
369
|
+
setTimeout(() => finish(false, `命令超时(${timeoutMs}ms)`), 3000);
|
|
370
|
+
}, timeoutMs);
|
|
371
|
+
if (options.isCancelled !== undefined) {
|
|
372
|
+
cancelPoll = setInterval(() => {
|
|
373
|
+
if (options.isCancelled?.() !== true)
|
|
374
|
+
return;
|
|
375
|
+
if (cancelPoll !== undefined)
|
|
376
|
+
clearInterval(cancelPoll);
|
|
377
|
+
cancelPoll = undefined;
|
|
378
|
+
killTree(child);
|
|
379
|
+
setTimeout(() => finish(false, '已取消', true), 3000);
|
|
380
|
+
}, 500);
|
|
381
|
+
}
|
|
382
|
+
let stderr = '';
|
|
383
|
+
child.stderr?.on('data', (chunk) => {
|
|
384
|
+
const text = chunk.toString('utf8');
|
|
385
|
+
if (stderr.length < 20000)
|
|
386
|
+
stderr += text;
|
|
387
|
+
options.onStderr?.(text);
|
|
388
|
+
});
|
|
389
|
+
child.on('error', (error) => finish(false, `无法执行 ${command}:${error.message}`));
|
|
390
|
+
child.on('close', (code) => {
|
|
391
|
+
const cancelled = options.isCancelled?.() === true;
|
|
392
|
+
finish(code === 0 && !cancelled, stderr.trim(), cancelled);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** 从 git 的 --progress 输出里取百分比;Receiving/Resolving 段都算。 */
|
|
397
|
+
function parseGitPercent(text) {
|
|
398
|
+
const matches = [...text.matchAll(/(\d{1,3})%/g)];
|
|
399
|
+
if (matches.length === 0)
|
|
400
|
+
return undefined;
|
|
401
|
+
const last = Number(matches[matches.length - 1][1]);
|
|
402
|
+
if (!Number.isFinite(last))
|
|
403
|
+
return undefined;
|
|
404
|
+
return Math.max(0, Math.min(100, last));
|
|
405
|
+
}
|
|
406
|
+
async function cloneWithGit(git, url, dest, branch, timeoutMs, token, host, hooks) {
|
|
407
|
+
const args = ['clone', '--depth', '1', '--progress'];
|
|
408
|
+
if (branch !== '')
|
|
409
|
+
args.push('--branch', branch);
|
|
410
|
+
args.push(url, dest);
|
|
411
|
+
let lastPercent = 0;
|
|
412
|
+
const result = await runProcess(git, args, timeoutMs, {
|
|
413
|
+
env: authEnv(token, host),
|
|
414
|
+
onSpawn: hooks.onSpawn,
|
|
415
|
+
isCancelled: hooks.isCancelled,
|
|
416
|
+
onStderr: (text) => {
|
|
417
|
+
const percent = parseGitPercent(text);
|
|
418
|
+
if (percent === undefined)
|
|
419
|
+
return;
|
|
420
|
+
if (percent < lastPercent)
|
|
421
|
+
return;
|
|
422
|
+
lastPercent = percent;
|
|
423
|
+
hooks.onProgress?.(percent, '正在接收对象');
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
if (!result.ok && !result.cancelled) {
|
|
427
|
+
// 半途失败会留下残目录,清掉才能让 tarball 兜底或重试。
|
|
428
|
+
await rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
/** 无 git 或 git 失败时用 codeload tarball:下载 → 系统 tar 解压并剥掉顶层目录。 */
|
|
433
|
+
async function cloneWithTarball(parsed, dest, branch, timeoutMs, token, hooks) {
|
|
434
|
+
const ref = branch === '' ? 'HEAD' : `refs/heads/${branch}`;
|
|
435
|
+
const url = `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${ref}`;
|
|
436
|
+
let bytes;
|
|
437
|
+
try {
|
|
438
|
+
const headers = {};
|
|
439
|
+
if (token !== '')
|
|
440
|
+
headers.authorization = `Bearer ${token}`;
|
|
441
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
442
|
+
if (!response.ok) {
|
|
443
|
+
return { ok: false, error: `下载失败:HTTP ${response.status}(${url})`, cancelled: false };
|
|
444
|
+
}
|
|
445
|
+
hooks.onProgress?.(15, '正在下载压缩包');
|
|
446
|
+
const chunks = [];
|
|
447
|
+
let received = 0;
|
|
448
|
+
const reader = response.body?.getReader();
|
|
449
|
+
if (reader === undefined) {
|
|
450
|
+
const array = await response.arrayBuffer();
|
|
451
|
+
bytes = Buffer.from(array);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
for (;;) {
|
|
455
|
+
if (hooks.isCancelled?.() === true) {
|
|
456
|
+
await reader.cancel().catch(() => { });
|
|
457
|
+
return { ok: false, error: '已取消', cancelled: true };
|
|
458
|
+
}
|
|
459
|
+
const next = await reader.read();
|
|
460
|
+
if (next.done)
|
|
461
|
+
break;
|
|
462
|
+
chunks.push(next.value);
|
|
463
|
+
received += next.value.byteLength;
|
|
464
|
+
if (received > MAX_TARBALL_BYTES) {
|
|
465
|
+
await reader.cancel().catch(() => { });
|
|
466
|
+
return { ok: false, error: `仓库压缩包超过上限 ${MAX_TARBALL_BYTES} 字节`, cancelled: false };
|
|
467
|
+
}
|
|
468
|
+
// 有 content-length 时按字节算,没有就停在 90% 直到解压。
|
|
469
|
+
const total = Number(response.headers.get('content-length') ?? '0');
|
|
470
|
+
const percent = total > 0 ? Math.min(90, 15 + Math.round((received / total) * 75)) : 50;
|
|
471
|
+
hooks.onProgress?.(percent, '正在下载压缩包');
|
|
472
|
+
}
|
|
473
|
+
bytes = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
474
|
+
}
|
|
475
|
+
if (bytes.byteLength > MAX_TARBALL_BYTES) {
|
|
476
|
+
return { ok: false, error: `仓库压缩包超过上限 ${MAX_TARBALL_BYTES} 字节`, cancelled: false };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
return {
|
|
481
|
+
ok: false,
|
|
482
|
+
error: `下载失败:${error instanceof Error ? error.message : String(error)}`,
|
|
483
|
+
cancelled: hooks.isCancelled?.() === true,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const tarball = join(tmpdir(), `dsh-gh-${Date.now()}-${parsed.repo}.tar.gz`);
|
|
487
|
+
try {
|
|
488
|
+
await writeFile(tarball, bytes);
|
|
489
|
+
await mkdir(dest, { recursive: true });
|
|
490
|
+
hooks.onProgress?.(92, '正在解压');
|
|
491
|
+
const result = await runProcess(resolveTarExecutable(), ['--strip-components=1', '-xzf', tarball, '-C', dest], timeoutMs, { onSpawn: hooks.onSpawn, isCancelled: hooks.isCancelled });
|
|
492
|
+
if (!result.ok) {
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
error: result.stderr === '' ? 'tar 解压失败' : result.stderr,
|
|
496
|
+
cancelled: result.cancelled,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
hooks.onProgress?.(100, '完成');
|
|
500
|
+
return { ok: true, error: '', cancelled: false };
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error), cancelled: false };
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
await rm(tarball, { force: true }).catch(() => { });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function directoryIsNonEmpty(path) {
|
|
510
|
+
try {
|
|
511
|
+
const entries = await readdir(path);
|
|
512
|
+
return entries.length > 0;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
export function apply(ctx) {
|
|
519
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
520
|
+
// 新变量名优先,旧名保留向后兼容;默认目录名保持 github-workspaces,
|
|
521
|
+
// 避免已克隆到该目录的工作区失效。
|
|
522
|
+
const defaultRoot = process.env.DSH_GIT_WORKSPACE_ROOT ||
|
|
523
|
+
process.env.DSH_GITHUB_WORKSPACE_ROOT ||
|
|
524
|
+
join(dshHome, 'github-workspaces');
|
|
525
|
+
const timeoutMs = DEFAULT_CLONE_TIMEOUT_MS;
|
|
526
|
+
const jobs = new Map();
|
|
527
|
+
const sweepJobs = () => {
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
for (const [id, running] of jobs) {
|
|
530
|
+
const finished = running.job.finishedAt;
|
|
531
|
+
if (finished !== undefined && now - finished > JOB_RETENTION_MS)
|
|
532
|
+
jobs.delete(id);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
/** 后台跑一次拉取;进度写进 job,客户端轮询读取。 */
|
|
536
|
+
const runJob = async (running, parsed, dest, branchRaw, parentDir, token) => {
|
|
537
|
+
const { job } = running;
|
|
538
|
+
const progress = (percent, detail) => {
|
|
539
|
+
job.percent = Math.max(job.percent, percent);
|
|
540
|
+
job.detail = detail;
|
|
541
|
+
};
|
|
542
|
+
try {
|
|
543
|
+
await mkdir(parentDir, { recursive: true });
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
job.state = 'failed';
|
|
547
|
+
job.error = `无法创建父目录:${error instanceof Error ? error.message : String(error)}`;
|
|
548
|
+
job.finishedAt = Date.now();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const git = await resolveGitExecutable();
|
|
552
|
+
let gitError = '';
|
|
553
|
+
if (git !== null && !running.cancelled) {
|
|
554
|
+
ctx.logger?.info?.(`[dsh-git-workspace] git clone ${parsed.label} -> ${dest}`);
|
|
555
|
+
progress(3, '正在连接');
|
|
556
|
+
const attempt = await cloneWithGit(git, parsed.url, dest, branchRaw, timeoutMs, token, parsed.host, {
|
|
557
|
+
onProgress: progress,
|
|
558
|
+
onSpawn: (child) => {
|
|
559
|
+
running.child = child;
|
|
560
|
+
},
|
|
561
|
+
isCancelled: () => running.cancelled,
|
|
562
|
+
});
|
|
563
|
+
if (attempt.ok) {
|
|
564
|
+
job.state = 'done';
|
|
565
|
+
job.percent = 100;
|
|
566
|
+
job.detail = '完成';
|
|
567
|
+
job.path = dest;
|
|
568
|
+
job.method = 'git';
|
|
569
|
+
job.finishedAt = Date.now();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (attempt.cancelled || running.cancelled) {
|
|
573
|
+
job.state = 'cancelled';
|
|
574
|
+
job.detail = '已取消';
|
|
575
|
+
job.finishedAt = Date.now();
|
|
576
|
+
await rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
gitError = attempt.stderr;
|
|
580
|
+
ctx.logger?.warn?.(`[dsh-git-workspace] git clone 失败:${gitError.slice(0, 200)}`);
|
|
581
|
+
progress(10, '准备兜底');
|
|
582
|
+
}
|
|
583
|
+
else if (git === null) {
|
|
584
|
+
ctx.logger?.warn?.('[dsh-git-workspace] 未找到 git 可执行文件');
|
|
585
|
+
}
|
|
586
|
+
// git 分支可能已被取消:进入下载前先早退,别让 tarball 白跑一趟。
|
|
587
|
+
if (running.cancelled) {
|
|
588
|
+
job.state = 'cancelled';
|
|
589
|
+
job.detail = '已取消';
|
|
590
|
+
job.finishedAt = Date.now();
|
|
591
|
+
await rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// 只有 GitHub 有 codeload 归档接口;其他主机的唯一路径就是 git。
|
|
595
|
+
if (!parsed.isGitHub) {
|
|
596
|
+
job.state = 'failed';
|
|
597
|
+
job.error =
|
|
598
|
+
git === null
|
|
599
|
+
? `未找到 git,且 ${parsed.host} 没有可用的归档兜底。请安装 git 或设置 DSH_GIT_EXE。`
|
|
600
|
+
: `git clone 失败,且 ${parsed.host} 没有可用的归档兜底。git 输出:${gitError.slice(0, 600)}`;
|
|
601
|
+
job.finishedAt = Date.now();
|
|
602
|
+
await rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const fallback = await cloneWithTarball(parsed, dest, branchRaw, timeoutMs, token, {
|
|
606
|
+
onProgress: progress,
|
|
607
|
+
onSpawn: (child) => {
|
|
608
|
+
running.child = child;
|
|
609
|
+
},
|
|
610
|
+
isCancelled: () => running.cancelled,
|
|
611
|
+
});
|
|
612
|
+
if (fallback.ok) {
|
|
613
|
+
job.state = 'done';
|
|
614
|
+
job.percent = 100;
|
|
615
|
+
job.detail = '完成';
|
|
616
|
+
job.path = dest;
|
|
617
|
+
job.method = 'tarball';
|
|
618
|
+
job.finishedAt = Date.now();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (fallback.cancelled || running.cancelled) {
|
|
622
|
+
job.state = 'cancelled';
|
|
623
|
+
job.detail = '已取消';
|
|
624
|
+
job.finishedAt = Date.now();
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
job.state = 'failed';
|
|
628
|
+
// 认证类失败单独给出可操作的提示:本机凭据没覆盖这个仓库时,让用户填令牌。
|
|
629
|
+
const authLike = /(Authentication failed|could not read Username|terminal prompts disabled|Invalid username or password|Repository not found|403)/i.test(gitError);
|
|
630
|
+
job.error = authLike
|
|
631
|
+
? `看起来是权限问题:本机 git 凭据(credential.helper)没有覆盖该仓库。请在「访问令牌」里填入对应的 Personal Access Token 后重试。git 输出:${gitError.slice(0, 400)};tarball:${fallback.error.slice(0, 300)}`
|
|
632
|
+
: `git clone 与 tarball 均失败。git:${gitError === '' ? '不可用' : gitError.slice(0, 600)};tarball:${fallback.error.slice(0, 600)}`;
|
|
633
|
+
job.finishedAt = Date.now();
|
|
634
|
+
}
|
|
635
|
+
await rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
636
|
+
};
|
|
637
|
+
const handler = async (req, res) => {
|
|
638
|
+
// 三重校验:回环来源 + 回环 Host + JSON content-type(+ Origin 同源)。
|
|
639
|
+
if (!isTrustedRequest(req)) {
|
|
640
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: '仅允许本机页面调用' } });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const url = new URL(req.url ?? '/', 'http://gh-workspace.internal');
|
|
644
|
+
const pathname = url.pathname;
|
|
645
|
+
const method = pathname.startsWith(ROUTE_PREFIX + '/')
|
|
646
|
+
? pathname.slice(ROUTE_PREFIX.length + 1)
|
|
647
|
+
: undefined;
|
|
648
|
+
sweepJobs();
|
|
649
|
+
// 进行中任务列表:角落浮层在页面重载后用它恢复显示。
|
|
650
|
+
if (method === 'jobs' && req.method === 'GET') {
|
|
651
|
+
const active = [...jobs.values()]
|
|
652
|
+
.filter((running) => running.job.state === 'running')
|
|
653
|
+
.map((running) => serializeJob(running.job));
|
|
654
|
+
writeJson(res, 200, { ok: true, value: { jobs: active } });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (method === 'job' && req.method === 'GET') {
|
|
658
|
+
const id = url.searchParams.get('id') ?? '';
|
|
659
|
+
const running = jobs.get(id);
|
|
660
|
+
if (running === undefined) {
|
|
661
|
+
writeJson(res, 404, { ok: false, error: { code: 'no-job', message: '任务不存在或已过期' } });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
writeJson(res, 200, { ok: true, value: serializeJob(running.job) });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (method === 'job/cancel' && req.method === 'POST') {
|
|
668
|
+
if (req.method !== 'POST') {
|
|
669
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: '只接受 POST' } });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
let payload;
|
|
673
|
+
try {
|
|
674
|
+
payload = await readJsonBody(req);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
payload = {};
|
|
678
|
+
}
|
|
679
|
+
const running = jobs.get(String(payload?.id ?? ''));
|
|
680
|
+
if (running === undefined) {
|
|
681
|
+
writeJson(res, 404, { ok: false, error: { code: 'no-job', message: '任务不存在或已过期' } });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
running.cancelled = true;
|
|
685
|
+
try {
|
|
686
|
+
running.child?.kill();
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
/* 已退出 */
|
|
690
|
+
}
|
|
691
|
+
writeJson(res, 200, { ok: true, value: { cancelled: true } });
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (method !== 'clone') {
|
|
695
|
+
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: '未知方法' } });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (req.method !== 'POST') {
|
|
699
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: '只接受 POST' } });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
let payload;
|
|
703
|
+
try {
|
|
704
|
+
payload = await readJsonBody(req);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
708
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-json', message } });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const parsed = parseRepo(payload?.repo);
|
|
712
|
+
if (!parsed) {
|
|
713
|
+
writeJson(res, 400, {
|
|
714
|
+
ok: false,
|
|
715
|
+
error: { code: 'bad-repo', message: '仓库格式应为 owner/repo 或 https://github.com/owner/repo' },
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const branchRaw = typeof payload?.branch === 'string' ? payload.branch.trim() : '';
|
|
720
|
+
if (branchRaw !== '' && (!BRANCH_RE.test(branchRaw) || branchRaw.includes('..'))) {
|
|
721
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-branch', message: '分支名不合法' } });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const parentRaw = typeof payload?.parentDir === 'string' ? payload.parentDir.trim() : '';
|
|
725
|
+
let parentDir;
|
|
726
|
+
if (parentRaw === '') {
|
|
727
|
+
parentDir = defaultRoot;
|
|
728
|
+
}
|
|
729
|
+
else if (!isAbsolute(parentRaw)) {
|
|
730
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-parent', message: '父目录必须是绝对路径' } });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
// 用户指定的父目录必须已存在且是目录:不替调用方创建任意路径,
|
|
735
|
+
// 把「往哪写」限制在用户事先建好的位置。
|
|
736
|
+
const info = await stat(parentRaw).catch(() => null);
|
|
737
|
+
if (info === null || !info.isDirectory()) {
|
|
738
|
+
writeJson(res, 400, {
|
|
739
|
+
ok: false,
|
|
740
|
+
error: { code: 'bad-parent', message: `父目录不存在或不是目录:${parentRaw}` },
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
parentDir = parentRaw;
|
|
745
|
+
}
|
|
746
|
+
// 并发上限:每个任务都会下载上百 MB,不设上限可被反复提交打满磁盘。
|
|
747
|
+
const runningCount = [...jobs.values()].filter((r) => r.job.state === 'running').length;
|
|
748
|
+
if (runningCount >= MAX_CONCURRENT_JOBS) {
|
|
749
|
+
writeJson(res, 429, {
|
|
750
|
+
ok: false,
|
|
751
|
+
error: { code: 'too-many-jobs', message: `同时最多 ${MAX_CONCURRENT_JOBS} 个克隆任务,请等待完成` },
|
|
752
|
+
});
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// 凭据只用于本次任务,不落盘;客户端传的 token 优先,其次读环境变量。
|
|
756
|
+
const tokenRaw = typeof payload?.token === 'string' ? payload.token.trim() : '';
|
|
757
|
+
const token = tokenRaw !== ''
|
|
758
|
+
? tokenRaw
|
|
759
|
+
: (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '').trim();
|
|
760
|
+
const dest = join(parentDir, parsed.repo);
|
|
761
|
+
// 同一目标目录已有在跑的任务:直接返回它,避免两个任务互相覆盖/互删。
|
|
762
|
+
const conflicting = [...jobs.values()].find((running) => running.job.state === 'running' && running.job.dest.toLowerCase() === dest.toLowerCase());
|
|
763
|
+
if (conflicting !== undefined) {
|
|
764
|
+
writeJson(res, 200, { ok: true, value: serializeJob(conflicting.job) });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const job = {
|
|
768
|
+
id: randomUUID(),
|
|
769
|
+
repo: parsed.label,
|
|
770
|
+
dest,
|
|
771
|
+
state: 'running',
|
|
772
|
+
percent: 0,
|
|
773
|
+
detail: '排队中',
|
|
774
|
+
startedAt: Date.now(),
|
|
775
|
+
};
|
|
776
|
+
const running = { job, cancelled: false };
|
|
777
|
+
jobs.set(job.id, running);
|
|
778
|
+
// 已存在且有内容:直接标记完成,不重复下载。
|
|
779
|
+
if (await pathExists(dest)) {
|
|
780
|
+
if (await directoryIsNonEmpty(dest)) {
|
|
781
|
+
job.state = 'done';
|
|
782
|
+
job.percent = 100;
|
|
783
|
+
job.detail = '已存在,直接复用';
|
|
784
|
+
job.path = dest;
|
|
785
|
+
job.method = 'existing';
|
|
786
|
+
job.finishedAt = Date.now();
|
|
787
|
+
writeJson(res, 200, { ok: true, value: serializeJob(job) });
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
void runJob(running, parsed, dest, branchRaw, parentDir, token).catch((error) => {
|
|
792
|
+
job.state = 'failed';
|
|
793
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
794
|
+
job.finishedAt = Date.now();
|
|
795
|
+
});
|
|
796
|
+
writeJson(res, 200, { ok: true, value: serializeJob(job) });
|
|
797
|
+
};
|
|
798
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler }), 'dsh-git-workspace: /gh-workspace routes');
|
|
799
|
+
ctx.logger?.info?.(`[dsh-git-workspace] clone 路由就绪:POST ${ROUTE_PREFIX}/clone`);
|
|
800
|
+
}
|
|
801
|
+
function serializeJob(job) {
|
|
802
|
+
return {
|
|
803
|
+
id: job.id,
|
|
804
|
+
repo: job.repo,
|
|
805
|
+
state: job.state,
|
|
806
|
+
percent: job.percent,
|
|
807
|
+
detail: job.detail,
|
|
808
|
+
path: job.path,
|
|
809
|
+
method: job.method,
|
|
810
|
+
error: job.error,
|
|
811
|
+
startedAt: job.startedAt,
|
|
812
|
+
finishedAt: job.finishedAt,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
//# sourceMappingURL=index.js.map
|