texlite 0.2.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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +403 -0
  3. package/README.zh-CN.md +391 -0
  4. package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
  5. package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
  6. package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
  7. package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
  8. package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
  9. package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
  10. package/dist/client/assets/editor-JxT6uSYX.js +14 -0
  11. package/dist/client/assets/index-Bc5CmFMl.js +15 -0
  12. package/dist/client/assets/index-CMqYAbDx.css +1 -0
  13. package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
  14. package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
  15. package/dist/client/assets/plus-DzbLkp6l.js +1 -0
  16. package/dist/client/assets/react-CsO_xvbl.js +49 -0
  17. package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
  18. package/dist/client/index.html +20 -0
  19. package/dist/client/logo.svg +4 -0
  20. package/dist/server/anchors.js +95 -0
  21. package/dist/server/app.js +1624 -0
  22. package/dist/server/archive.js +26 -0
  23. package/dist/server/auth.js +42 -0
  24. package/dist/server/cli.js +318 -0
  25. package/dist/server/collaboration.js +635 -0
  26. package/dist/server/compileArtifacts.js +86 -0
  27. package/dist/server/compileDiagnostics.js +227 -0
  28. package/dist/server/compiler.js +514 -0
  29. package/dist/server/config.js +336 -0
  30. package/dist/server/db.js +280 -0
  31. package/dist/server/environment.js +57 -0
  32. package/dist/server/files.js +121 -0
  33. package/dist/server/git.js +414 -0
  34. package/dist/server/history.js +379 -0
  35. package/dist/server/http.js +7 -0
  36. package/dist/server/index.js +75 -0
  37. package/dist/server/latexCompletion.js +296 -0
  38. package/dist/server/metrics.js +34 -0
  39. package/dist/server/pm2.js +234 -0
  40. package/dist/server/projectOutline.js +101 -0
  41. package/dist/server/projectSearch.js +112 -0
  42. package/dist/server/projects.js +29 -0
  43. package/dist/server/routes/compile.js +343 -0
  44. package/dist/server/runtimePaths.js +42 -0
  45. package/dist/server/security.js +29 -0
  46. package/dist/server/spellCheck.js +195 -0
  47. package/dist/server/synctex.js +56 -0
  48. package/dist/server/zip.js +185 -0
  49. package/package.json +110 -0
  50. package/preview.png +0 -0
  51. package/texlite.config.example.json +33 -0
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function projectRoot(config, projectId) {
4
+ return path.join(config.projectsDir, projectId);
5
+ }
6
+ export function sourceRoot(config, projectId) {
7
+ return path.join(projectRoot(config, projectId), "source");
8
+ }
9
+ export function outputRoot(config, projectId) {
10
+ return path.join(projectRoot(config, projectId), "output");
11
+ }
12
+ export function createProjectFiles(config, projectId) {
13
+ const source = sourceRoot(config, projectId);
14
+ fs.mkdirSync(source, { recursive: true, mode: 0o700 });
15
+ fs.mkdirSync(outputRoot(config, projectId), { recursive: true, mode: 0o700 });
16
+ fs.writeFileSync(path.join(source, "main.tex"), `\\documentclass{article}
17
+ \\title{New Project}
18
+ \\author{}
19
+ \\date{\\today}
20
+
21
+ \\begin{document}
22
+ \\maketitle
23
+
24
+ \\section{Introduction}
25
+ Start writing here.
26
+
27
+ \\end{document}
28
+ `, { encoding: "utf8", mode: 0o600 });
29
+ }
30
+ export function duplicateProjectFiles(config, sourceProjectId, targetProjectId) {
31
+ const source = sourceRoot(config, sourceProjectId);
32
+ const target = sourceRoot(config, targetProjectId);
33
+ fs.mkdirSync(target, { recursive: true, mode: 0o700 });
34
+ if (fs.existsSync(source)) {
35
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
36
+ if (entry.name === ".git")
37
+ continue;
38
+ fs.cpSync(path.join(source, entry.name), path.join(target, entry.name), {
39
+ recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true
40
+ });
41
+ }
42
+ }
43
+ fs.mkdirSync(outputRoot(config, targetProjectId), { recursive: true, mode: 0o700 });
44
+ }
45
+ export function safeRelativePath(input) {
46
+ if (!input || input.includes("\0") || path.isAbsolute(input)) {
47
+ throw Object.assign(new Error("无效的文件路径"), { code: "INVALID_PATH" });
48
+ }
49
+ const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
50
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
51
+ throw Object.assign(new Error("无效的文件路径"), { code: "INVALID_PATH" });
52
+ }
53
+ if (normalized.split("/").some((segment) => segment.toLocaleLowerCase() === ".git")) {
54
+ throw Object.assign(new Error(".git 是系统保留目录"), { code: "RESERVED_PATH" });
55
+ }
56
+ return normalized;
57
+ }
58
+ export function resolveSourcePath(config, projectId, input) {
59
+ return path.join(sourceRoot(config, projectId), safeRelativePath(input));
60
+ }
61
+ export function listProjectFiles(config, projectId) {
62
+ const root = sourceRoot(config, projectId);
63
+ const result = [];
64
+ const visit = (directory, prefix) => {
65
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
66
+ if (entry.name === ".git")
67
+ continue;
68
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
69
+ const absolute = path.join(directory, entry.name);
70
+ if (entry.isDirectory()) {
71
+ result.push({ path: relative, type: "directory" });
72
+ visit(absolute, relative);
73
+ }
74
+ else if (entry.isFile()) {
75
+ result.push({ path: relative, type: "file", size: fs.statSync(absolute).size });
76
+ }
77
+ }
78
+ };
79
+ if (fs.existsSync(root))
80
+ visit(root, "");
81
+ return result;
82
+ }
83
+ export async function listProjectFilesAsync(config, projectId) {
84
+ const root = sourceRoot(config, projectId);
85
+ const result = [];
86
+ const visit = async (directory, prefix) => {
87
+ const entries = await fs.promises.readdir(directory, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (entry.name === ".git")
90
+ continue;
91
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
92
+ const absolute = path.join(directory, entry.name);
93
+ if (entry.isDirectory()) {
94
+ result.push({ path: relative, type: "directory" });
95
+ await visit(absolute, relative);
96
+ }
97
+ else if (entry.isFile()) {
98
+ const stat = await fs.promises.stat(absolute);
99
+ result.push({ path: relative, type: "file", size: stat.size, mtimeMs: stat.mtimeMs });
100
+ }
101
+ }
102
+ };
103
+ try {
104
+ await visit(root, "");
105
+ }
106
+ catch (error) {
107
+ if (error.code !== "ENOENT")
108
+ throw error;
109
+ }
110
+ return result;
111
+ }
112
+ export function removeProjectDirectory(config, projectId) {
113
+ const root = projectRoot(config, projectId);
114
+ if (!fs.existsSync(root))
115
+ return;
116
+ const trash = path.join(config.dataDir, "trash");
117
+ fs.mkdirSync(trash, { recursive: true, mode: 0o700 });
118
+ const target = path.join(trash, `${projectId}-${Date.now()}`);
119
+ fs.renameSync(root, target);
120
+ fs.rmSync(target, { recursive: true, force: true });
121
+ }
@@ -0,0 +1,414 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
4
+ import { spawn } from "node:child_process";
5
+ import { assertGitAvailable } from "./environment.js";
6
+ import { sourceRoot } from "./files.js";
7
+ const MAX_COMMAND_OUTPUT = 8 * 1024 * 1024;
8
+ const MAX_DIFF_OUTPUT = 1024 * 1024;
9
+ export class ProjectGitService {
10
+ config;
11
+ db;
12
+ githubFetch;
13
+ locks = new Map();
14
+ gitAvailability = null;
15
+ constructor(config, db, githubFetch = fetch) {
16
+ this.config = config;
17
+ this.db = db;
18
+ this.githubFetch = githubFetch;
19
+ }
20
+ async status(project) {
21
+ await this.ensureGitAvailable();
22
+ const settings = this.settings(project.id);
23
+ const initialized = fs.existsSync(path.join(sourceRoot(this.config, project.id), ".git"));
24
+ if (!initialized)
25
+ return {
26
+ initialized: false,
27
+ tokenConfigured: Boolean(settings?.token_ciphertext),
28
+ githubLogin: settings?.github_login ?? null,
29
+ remoteUrl: settings?.remote_url ?? null,
30
+ repositoryName: settings?.repository_name ?? null,
31
+ repositoryHtmlUrl: settings?.repository_html_url ?? null,
32
+ defaultBranch: settings?.default_branch ?? "main",
33
+ branch: null,
34
+ dirty: false,
35
+ restorable: false,
36
+ changedFiles: 0,
37
+ ahead: 0,
38
+ latestCommit: null
39
+ };
40
+ const root = sourceRoot(this.config, project.id);
41
+ const porcelain = (await this.git(root, ["status", "--porcelain=v1"])).stdout.trim();
42
+ const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
43
+ const branch = branchResult.code === 0 ? branchResult.stdout.trim() : null;
44
+ const remoteResult = await this.git(root, ["remote", "get-url", "origin"], [2]);
45
+ const latestResult = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"], [128]);
46
+ const aheadResult = await this.git(root, ["rev-list", "--count", "@{upstream}..HEAD"], [128]);
47
+ return {
48
+ initialized: true,
49
+ tokenConfigured: Boolean(settings?.token_ciphertext),
50
+ githubLogin: settings?.github_login ?? null,
51
+ remoteUrl: settings?.remote_url ?? (remoteResult.code === 0 ? remoteResult.stdout.trim() : null),
52
+ repositoryName: settings?.repository_name ?? null,
53
+ repositoryHtmlUrl: settings?.repository_html_url ?? null,
54
+ defaultBranch: settings?.default_branch ?? branch ?? "main",
55
+ branch,
56
+ dirty: Boolean(porcelain),
57
+ restorable: porcelain.split("\n").some((line) => Boolean(line) && !line.startsWith("??")),
58
+ changedFiles: porcelain ? porcelain.split("\n").length : 0,
59
+ ahead: aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
60
+ latestCommit: latestResult.code === 0 ? parseCommit(latestResult.stdout.trim()) : null
61
+ };
62
+ }
63
+ async configureToken(project, tokenInput) {
64
+ const token = tokenInput.trim();
65
+ if (token.length < 20 || token.length > 500)
66
+ throw httpError(400, "GitHub token 格式不正确");
67
+ await this.ensureGitAvailable();
68
+ return this.exclusive(project.id, async () => {
69
+ const account = await this.githubRequest(token, "/user", { method: "GET" });
70
+ if (typeof account.login !== "string" || !account.login)
71
+ throw httpError(502, "GitHub 没有返回有效的用户信息");
72
+ await this.ensureRepository(project);
73
+ const timestamp = new Date().toISOString();
74
+ this.db.prepare(`INSERT INTO project_git_settings
75
+ (project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
76
+ VALUES (?, ?, ?, 'main', ?, ?)
77
+ ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
78
+ github_login = excluded.github_login, updated_at = excluded.updated_at`)
79
+ .run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
80
+ return this.status(project);
81
+ });
82
+ }
83
+ async removeToken(project) {
84
+ return this.exclusive(project.id, async () => {
85
+ this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
86
+ .run(new Date().toISOString(), project.id);
87
+ return this.status(project);
88
+ });
89
+ }
90
+ async createGitHubRepository(project, name, isPrivate) {
91
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
92
+ throw httpError(400, "GitHub 仓库名称只能包含字母、数字、点、横线或下划线");
93
+ await this.ensureGitAvailable();
94
+ return this.exclusive(project.id, async () => {
95
+ const settings = this.requireTokenSettings(project.id);
96
+ if (settings.remote_url)
97
+ throw httpError(409, "该项目已经配置远程仓库");
98
+ const token = decryptToken(this.config, settings.token_ciphertext);
99
+ const repository = await this.githubRequest(token, "/user/repos", {
100
+ method: "POST",
101
+ body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
102
+ });
103
+ if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
104
+ throw httpError(502, "GitHub 没有返回有效的仓库地址");
105
+ }
106
+ await this.ensureRepository(project);
107
+ const root = sourceRoot(this.config, project.id);
108
+ const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
109
+ if (currentRemote.code === 0)
110
+ await this.git(root, ["remote", "set-url", "origin", repository.clone_url]);
111
+ else
112
+ await this.git(root, ["remote", "add", "origin", repository.clone_url]);
113
+ const defaultBranch = typeof repository.default_branch === "string" && repository.default_branch
114
+ ? repository.default_branch : "main";
115
+ this.db.prepare(`UPDATE project_git_settings SET remote_url = ?, repository_name = ?,
116
+ repository_html_url = ?, default_branch = ?, updated_at = ? WHERE project_id = ?`)
117
+ .run(repository.clone_url, typeof repository.name === "string" ? repository.name : name, repository.html_url, defaultBranch, new Date().toISOString(), project.id);
118
+ return this.status(project);
119
+ });
120
+ }
121
+ async commit(project, owner, messageInput) {
122
+ const message = messageInput.trim();
123
+ if (!message || message.length > 300)
124
+ throw httpError(400, "提交说明不能为空且不能超过 300 个字符");
125
+ return this.exclusive(project.id, async () => {
126
+ const root = this.requireRepository(project.id);
127
+ const branch = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
128
+ if (branch.code !== 0)
129
+ throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再提交");
130
+ await this.git(root, ["add", "--all"]);
131
+ const staged = await this.git(root, ["diff", "--cached", "--quiet"], [1]);
132
+ if (staged.code === 0)
133
+ throw httpError(409, "当前没有可以提交的修改");
134
+ await this.git(root, [
135
+ "-c", `user.name=${owner.username}`,
136
+ "-c", `user.email=${owner.username}@texlite.com`,
137
+ "-c", "commit.gpgSign=false",
138
+ "-c", "core.hooksPath=/dev/null",
139
+ "commit", "-m", message
140
+ ]);
141
+ const result = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"]);
142
+ return parseCommit(result.stdout.trim());
143
+ });
144
+ }
145
+ async push(project) {
146
+ return this.exclusive(project.id, async () => {
147
+ const settings = this.requireTokenSettings(project.id);
148
+ if (!settings.remote_url)
149
+ throw httpError(409, "请先创建 GitHub 仓库");
150
+ const root = this.requireRepository(project.id);
151
+ const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
152
+ if (head.code !== 0)
153
+ throw httpError(409, "请先提交项目修改");
154
+ const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
155
+ if (branchResult.code !== 0)
156
+ throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再推送");
157
+ const branch = branchResult.stdout.trim();
158
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch))
159
+ throw httpError(400, "本地分支名称无效");
160
+ const token = decryptToken(this.config, settings.token_ciphertext);
161
+ await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
162
+ return this.status(project);
163
+ });
164
+ }
165
+ async history(project) {
166
+ const root = this.requireRepository(project.id);
167
+ const result = await this.git(root, ["log", "--all", "-n", "100", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"], [128]);
168
+ if (result.code !== 0 || !result.stdout.trim())
169
+ return [];
170
+ return result.stdout.trim().split("\n").map(parseCommit);
171
+ }
172
+ async diff(project, revision) {
173
+ const root = this.requireRepository(project.id);
174
+ let title = "Working tree";
175
+ let output = "";
176
+ if (revision) {
177
+ const sha = validateRevision(revision);
178
+ await this.verifyCommit(root, sha);
179
+ title = sha;
180
+ output = (await this.git(root, ["show", "--no-ext-diff", "--format=fuller", "--unified=3", sha, "--"])).stdout;
181
+ }
182
+ else {
183
+ const hasHead = (await this.git(root, ["rev-parse", "--verify", "HEAD"], [128])).code === 0;
184
+ if (hasHead)
185
+ output = (await this.git(root, ["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"])).stdout;
186
+ const untracked = (await this.git(root, ["ls-files", "--others", "--exclude-standard", "-z"])).stdout
187
+ .split("\0").filter(Boolean).slice(0, 100);
188
+ for (const file of untracked) {
189
+ const addition = await this.git(root, ["diff", "--no-index", "--", "/dev/null", file], [1]);
190
+ output += `${output && !output.endsWith("\n") ? "\n" : ""}${addition.stdout}`;
191
+ if (Buffer.byteLength(output, "utf8") > MAX_DIFF_OUTPUT)
192
+ break;
193
+ }
194
+ }
195
+ const bytes = Buffer.byteLength(output, "utf8");
196
+ return {
197
+ title,
198
+ diff: bytes > MAX_DIFF_OUTPUT ? Buffer.from(output).subarray(0, MAX_DIFF_OUTPUT).toString("utf8") : output,
199
+ truncated: bytes > MAX_DIFF_OUTPUT
200
+ };
201
+ }
202
+ async checkout(project, revisionInput, force) {
203
+ return this.exclusive(project.id, async () => {
204
+ const root = this.requireRepository(project.id);
205
+ const settings = this.settings(project.id);
206
+ const revision = revisionInput === null ? settings?.default_branch ?? "main" : validateRevision(revisionInput);
207
+ const target = revisionInput === null ? revision : await this.verifyCommit(root, revision);
208
+ if (force) {
209
+ await this.git(root, ["reset", "--hard", "HEAD"]);
210
+ await this.git(root, ["clean", "-fdx"]);
211
+ }
212
+ await this.git(root, ["-c", "core.hooksPath=/dev/null", "checkout", ...(revisionInput === null ? [] : ["--detach"]), ...(force ? ["--force"] : []), target]);
213
+ return target;
214
+ });
215
+ }
216
+ async discardChanges(project) {
217
+ return this.exclusive(project.id, async () => {
218
+ const root = this.requireRepository(project.id);
219
+ const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
220
+ if (head.code !== 0)
221
+ throw httpError(409, "该项目尚无可恢复的提交");
222
+ await this.git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."]);
223
+ });
224
+ }
225
+ async ensureRepository(project) {
226
+ const root = sourceRoot(this.config, project.id);
227
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
228
+ if (fs.existsSync(path.join(root, ".git")))
229
+ return;
230
+ await this.git(root, ["init"]);
231
+ await this.git(root, ["symbolic-ref", "HEAD", "refs/heads/main"]);
232
+ }
233
+ requireRepository(projectId) {
234
+ const root = sourceRoot(this.config, projectId);
235
+ if (!fs.existsSync(path.join(root, ".git")))
236
+ throw httpError(409, "该项目尚未初始化 Git");
237
+ return root;
238
+ }
239
+ settings(projectId) {
240
+ return this.db.prepare("SELECT * FROM project_git_settings WHERE project_id = ?").get(projectId);
241
+ }
242
+ requireTokenSettings(projectId) {
243
+ const settings = this.settings(projectId);
244
+ if (!settings?.token_ciphertext)
245
+ throw httpError(409, "请先配置 GitHub token");
246
+ return settings;
247
+ }
248
+ async verifyCommit(root, revision) {
249
+ const result = await this.git(root, ["rev-parse", "--verify", `${revision}^{commit}`], [128]);
250
+ if (result.code !== 0)
251
+ throw httpError(404, "Git 版本不存在");
252
+ return result.stdout.trim();
253
+ }
254
+ async githubRequest(token, route, init) {
255
+ const controller = new AbortController();
256
+ const timer = setTimeout(() => controller.abort(), this.config.gitOperationTimeoutMs);
257
+ try {
258
+ const response = await this.githubFetch(`${this.config.githubApiBaseUrl}${route}`, {
259
+ ...init,
260
+ signal: controller.signal,
261
+ headers: {
262
+ Accept: "application/vnd.github+json",
263
+ Authorization: `Bearer ${token}`,
264
+ "X-GitHub-Api-Version": "2026-03-10",
265
+ "User-Agent": "texLite",
266
+ ...(init.body ? { "Content-Type": "application/json" } : {})
267
+ }
268
+ });
269
+ const body = await response.json().catch(() => ({}));
270
+ if (!response.ok) {
271
+ const message = typeof body.message === "string" ? body.message : `HTTP ${response.status}`;
272
+ throw httpError(response.status === 401 || response.status === 403 ? 400 : 502, `GitHub 请求失败:${message}`);
273
+ }
274
+ return body;
275
+ }
276
+ catch (error) {
277
+ if (error instanceof Error && error.name === "AbortError")
278
+ throw httpError(504, "GitHub 请求超时");
279
+ throw error;
280
+ }
281
+ finally {
282
+ clearTimeout(timer);
283
+ }
284
+ }
285
+ authEnvironment(token) {
286
+ const helper = path.join(this.config.dataDir, "git-askpass.sh");
287
+ if (!fs.existsSync(helper)) {
288
+ fs.writeFileSync(helper, `#!/bin/sh\ncase "$1" in\n *Username*) printf '%s\\n' 'x-access-token' ;;\n *) printf '%s\\n' "$TEXLITE_GIT_TOKEN" ;;\nesac\n`, { mode: 0o700 });
289
+ }
290
+ return {
291
+ ...process.env,
292
+ GIT_ASKPASS: helper,
293
+ GIT_ASKPASS_REQUIRE: "force",
294
+ GIT_TERMINAL_PROMPT: "0",
295
+ TEXLITE_GIT_TOKEN: token
296
+ };
297
+ }
298
+ ensureGitAvailable() {
299
+ if (!this.gitAvailability) {
300
+ this.gitAvailability = assertGitAvailable(this.config)
301
+ .then(() => undefined)
302
+ .catch((error) => {
303
+ this.gitAvailability = null;
304
+ throw error;
305
+ });
306
+ }
307
+ return this.gitAvailability;
308
+ }
309
+ async git(cwd, args, allowedCodes = [], env = process.env) {
310
+ await this.ensureGitAvailable();
311
+ return new Promise((resolve, reject) => {
312
+ const child = spawn(this.config.git, args, { cwd, env, shell: false, stdio: ["ignore", "pipe", "pipe"] });
313
+ const stdout = [];
314
+ const stderr = [];
315
+ let size = 0;
316
+ let timedOut = false;
317
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, this.config.gitOperationTimeoutMs);
318
+ const collect = (target) => (chunk) => {
319
+ size += chunk.length;
320
+ if (size > MAX_COMMAND_OUTPUT)
321
+ child.kill("SIGKILL");
322
+ else
323
+ target.push(chunk);
324
+ };
325
+ child.stdout.on("data", collect(stdout));
326
+ child.stderr.on("data", collect(stderr));
327
+ child.once("error", (error) => { clearTimeout(timer); reject(httpError(500, `无法运行 Git:${error.message}`)); });
328
+ child.once("close", (code) => {
329
+ clearTimeout(timer);
330
+ const result = { stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), code: code ?? -1 };
331
+ if (timedOut)
332
+ return reject(httpError(504, "Git 操作超时"));
333
+ if (size > MAX_COMMAND_OUTPUT)
334
+ return reject(httpError(413, "Git 输出过大"));
335
+ if (result.code !== 0 && !allowedCodes.includes(result.code)) {
336
+ const detail = (result.stderr || result.stdout).trim().slice(0, 2000) || `exit ${result.code}`;
337
+ return reject(httpError(400, `Git 操作失败:${detail}`));
338
+ }
339
+ resolve(result);
340
+ });
341
+ });
342
+ }
343
+ async exclusive(projectId, operation) {
344
+ const previous = this.locks.get(projectId) ?? Promise.resolve();
345
+ let release = () => undefined;
346
+ const gate = new Promise((resolve) => { release = resolve; });
347
+ const tail = previous.catch(() => undefined).then(() => gate);
348
+ this.locks.set(projectId, tail);
349
+ await previous.catch(() => undefined);
350
+ try {
351
+ return await operation();
352
+ }
353
+ finally {
354
+ release();
355
+ if (this.locks.get(projectId) === tail)
356
+ this.locks.delete(projectId);
357
+ }
358
+ }
359
+ }
360
+ function parseCommit(line) {
361
+ const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
362
+ return { sha, shortSha, authorName, authorEmail, authoredAt, message: message.join("\x1f") };
363
+ }
364
+ function validateRevision(value) {
365
+ const revision = value.trim();
366
+ if (!/^[a-f0-9]{7,40}$/i.test(revision))
367
+ throw httpError(400, "Git 版本格式不正确");
368
+ return revision;
369
+ }
370
+ function tokenKey(config) {
371
+ const target = path.join(config.dataDir, "git-token.key");
372
+ if (!fs.existsSync(target)) {
373
+ fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
374
+ try {
375
+ fs.writeFileSync(target, randomBytes(32), { flag: "wx", mode: 0o600 });
376
+ }
377
+ catch (error) {
378
+ if (!fs.existsSync(target))
379
+ throw error;
380
+ }
381
+ }
382
+ const key = fs.readFileSync(target);
383
+ if (key.length !== 32)
384
+ throw httpError(500, "Git token 加密密钥无效");
385
+ return key;
386
+ }
387
+ function encryptToken(config, token) {
388
+ const iv = randomBytes(12);
389
+ const cipher = createCipheriv("aes-256-gcm", tokenKey(config), iv);
390
+ const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
391
+ return ["v1", iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), encrypted.toString("base64url")].join(".");
392
+ }
393
+ function decryptToken(config, encoded) {
394
+ const [version, ivText, tagText, encryptedText] = encoded.split(".");
395
+ if (version !== "v1" || !ivText || !tagText || !encryptedText)
396
+ throw httpError(500, "GitHub token 无法解密");
397
+ try {
398
+ const decipher = createDecipheriv("aes-256-gcm", tokenKey(config), Buffer.from(ivText, "base64url"));
399
+ decipher.setAuthTag(Buffer.from(tagText, "base64url"));
400
+ return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
401
+ }
402
+ catch {
403
+ throw httpError(500, "GitHub token 无法解密");
404
+ }
405
+ }
406
+ function httpError(statusCode, message) {
407
+ const code = statusCode === 400 ? "GIT_INVALID"
408
+ : statusCode === 404 ? "GIT_REVISION_NOT_FOUND"
409
+ : statusCode === 409 ? "GIT_CONFLICT"
410
+ : statusCode === 413 ? "GIT_OUTPUT_TOO_LARGE"
411
+ : statusCode === 504 ? "GIT_TIMEOUT"
412
+ : statusCode >= 500 ? "GIT_OPERATION_FAILED" : "REQUEST_INVALID";
413
+ return Object.assign(new Error(message), { statusCode, code });
414
+ }