plugin-git-manager 1.2.0 → 1.2.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/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"displayName": "Git Manager",
|
|
4
4
|
"displayName.zh-CN": "Git 管理器",
|
|
5
5
|
"description": "Manage Git repositories with PAT authentication - pull, push, fetch, diff, file browsing",
|
|
6
|
-
"version": "1.2.
|
|
6
|
+
"version": "1.2.1",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
8
|
"main": "dist/server/index.js",
|
|
9
9
|
"files": [
|
|
@@ -2,24 +2,27 @@ import simpleGit, { SimpleGit } from 'simple-git';
|
|
|
2
2
|
import { Context } from '@nocobase/actions';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import * as fs from 'fs';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
5
6
|
import { redactPat, redactError } from '../utils/redact';
|
|
6
7
|
|
|
7
8
|
// Disallow leading `-` to prevent argument-injection (e.g. `--upload-pack=...`)
|
|
8
9
|
// when refs are passed as positional args to git.
|
|
9
|
-
const REF_PATTERN = /^(?!-)[a-zA-Z0-9._
|
|
10
|
+
const REF_PATTERN = /^(?!-)[a-zA-Z0-9._/-]+$/;
|
|
10
11
|
|
|
11
12
|
// Per-repo mutex to prevent PAT race conditions in withAuth
|
|
12
13
|
const repoLocks = new Map<string, Promise<any>>();
|
|
14
|
+
const GIT_BINARY = process.env.GIT_BINARY_PATH || process.env.GIT_EXECUTABLE || 'git';
|
|
15
|
+
let gitAvailabilityChecked = false;
|
|
13
16
|
|
|
14
17
|
function acquireLock(key: string): { promise: Promise<void>; release: () => void } {
|
|
15
18
|
const prev = repoLocks.get(key) || Promise.resolve();
|
|
16
|
-
let release
|
|
19
|
+
let release = () => {};
|
|
17
20
|
const next = new Promise<void>((resolve) => {
|
|
18
21
|
release = resolve;
|
|
19
22
|
});
|
|
20
23
|
const promise = prev.then(() => {});
|
|
21
24
|
repoLocks.set(key, next);
|
|
22
|
-
return { promise, release
|
|
25
|
+
return { promise, release };
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
function validateRef(ref: string): string {
|
|
@@ -48,7 +51,14 @@ function validateRepoUrl(repoUrl: string): void {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
async function withAuth(
|
|
54
|
+
async function withAuth(
|
|
55
|
+
git: ReturnType<typeof simpleGit>,
|
|
56
|
+
localPath: string,
|
|
57
|
+
repoUrl: string,
|
|
58
|
+
pat: string,
|
|
59
|
+
fn: () => Promise<any>,
|
|
60
|
+
username?: string,
|
|
61
|
+
) {
|
|
52
62
|
// Lock by local working tree — that's what `git.remote('set-url', ...)`
|
|
53
63
|
// mutates. Two repo records sharing a `repoUrl` but cloned to different
|
|
54
64
|
// paths can run in parallel safely; conversely, two repos pointed at the
|
|
@@ -75,7 +85,7 @@ async function withAuth(git: ReturnType<typeof simpleGit>, localPath: string, re
|
|
|
75
85
|
// Log but don't throw — the original operation already completed
|
|
76
86
|
console.error(
|
|
77
87
|
`[plugin-git-manager] CRITICAL: failed to remove PAT from remote URL for ${redactPat(repoUrl)}. ` +
|
|
78
|
-
|
|
88
|
+
`Manual cleanup of .git/config may be required.`,
|
|
79
89
|
redactError(cleanupErr),
|
|
80
90
|
);
|
|
81
91
|
}
|
|
@@ -91,11 +101,35 @@ function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
|
|
|
91
101
|
return url.toString();
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
function getGitMissingMessage() {
|
|
105
|
+
return `Git executable "${GIT_BINARY}" was not found on the server. Install git in the app/worker container, or set GIT_BINARY_PATH to the absolute git binary path.`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertGitAvailable(ctx: Context) {
|
|
109
|
+
if (gitAvailabilityChecked) return;
|
|
110
|
+
|
|
111
|
+
const check = spawnSync(GIT_BINARY, ['--version'], { stdio: 'ignore' });
|
|
112
|
+
if (check.error || check.status !== 0) {
|
|
113
|
+
ctx.throw(503, getGitMissingMessage());
|
|
114
|
+
}
|
|
115
|
+
gitAvailabilityChecked = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isMissingGitError(error: any) {
|
|
119
|
+
const message = error?.message || String(error);
|
|
120
|
+
return error?.code === 'ENOENT' || /spawn .*git.*ENOENT/i.test(message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createGit(baseDir?: string): SimpleGit {
|
|
124
|
+
return simpleGit({ baseDir, binary: GIT_BINARY } as any);
|
|
125
|
+
}
|
|
126
|
+
|
|
94
127
|
function getGit(ctx: Context, localPath: string): SimpleGit {
|
|
95
128
|
if (!fs.existsSync(localPath)) {
|
|
96
129
|
ctx.throw(400, 'Repository directory does not exist. Please clone the repository first.');
|
|
97
130
|
}
|
|
98
|
-
|
|
131
|
+
assertGitAvailable(ctx);
|
|
132
|
+
return createGit(localPath);
|
|
99
133
|
}
|
|
100
134
|
|
|
101
135
|
async function getRepo(ctx: Context) {
|
|
@@ -113,11 +147,11 @@ async function getRepo(ctx: Context) {
|
|
|
113
147
|
function validateLocalPath(localPath: string): string {
|
|
114
148
|
const basePath = process.env.GIT_REPOS_BASE_PATH || path.join(process.cwd(), 'storage', 'git-repos');
|
|
115
149
|
const resolved = path.resolve(basePath, localPath);
|
|
116
|
-
|
|
150
|
+
|
|
117
151
|
// Ensure the resolved path is strictly inside the basePath.
|
|
118
152
|
// We add path.sep to prevent partial matches like /storage/git-repo-hack matching /storage/git-repo
|
|
119
153
|
const strictBasePath = path.resolve(basePath) + path.sep;
|
|
120
|
-
|
|
154
|
+
|
|
121
155
|
if (!resolved.startsWith(strictBasePath) && resolved !== path.resolve(basePath)) {
|
|
122
156
|
throw new Error('Invalid local path: path traversal detected or path is outside the allowed base directory');
|
|
123
157
|
}
|
|
@@ -127,9 +161,9 @@ function validateLocalPath(localPath: string): string {
|
|
|
127
161
|
export async function clone(ctx: Context, next: () => Promise<void>) {
|
|
128
162
|
const repo = await getRepo(ctx);
|
|
129
163
|
const localPath = validateLocalPath(repo.get('localPath'));
|
|
130
|
-
const repoUrl = (repo.get('repoUrl') as string || '').trim();
|
|
131
|
-
const pat = (repo.get('pat') as string || '').trim();
|
|
132
|
-
const username = (repo.get('username') as string || '').trim();
|
|
164
|
+
const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
|
|
165
|
+
const pat = ((repo.get('pat') as string) || '').trim();
|
|
166
|
+
const username = ((repo.get('username') as string) || '').trim();
|
|
133
167
|
const defaultBranch = ((repo.get('defaultBranch') as string) || 'main').trim() || 'main';
|
|
134
168
|
|
|
135
169
|
validateRepoUrl(repoUrl);
|
|
@@ -146,10 +180,11 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
|
|
|
146
180
|
}
|
|
147
181
|
|
|
148
182
|
const authUrl = getAuthUrl(repoUrl, pat, username);
|
|
183
|
+
assertGitAvailable(ctx);
|
|
149
184
|
try {
|
|
150
|
-
await
|
|
185
|
+
await createGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
|
|
151
186
|
// Remove PAT from the cloned repo's remote URL
|
|
152
|
-
await
|
|
187
|
+
await createGit(localPath).remote(['set-url', 'origin', repoUrl]);
|
|
153
188
|
await ctx.db.getRepository('gitRepositories').update({
|
|
154
189
|
filterByTk: repo.get('id'),
|
|
155
190
|
values: { status: 'connected' },
|
|
@@ -161,6 +196,9 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
|
|
|
161
196
|
values: { status: 'error' },
|
|
162
197
|
});
|
|
163
198
|
// Redact embedded PAT before the error reaches the client / log
|
|
199
|
+
if (isMissingGitError(err)) {
|
|
200
|
+
ctx.throw(503, getGitMissingMessage());
|
|
201
|
+
}
|
|
164
202
|
throw redactError(err);
|
|
165
203
|
}
|
|
166
204
|
await next();
|
|
@@ -169,9 +207,9 @@ export async function clone(ctx: Context, next: () => Promise<void>) {
|
|
|
169
207
|
export async function pull(ctx: Context, next: () => Promise<void>) {
|
|
170
208
|
const repo = await getRepo(ctx);
|
|
171
209
|
const localPath = validateLocalPath(repo.get('localPath'));
|
|
172
|
-
const pat = (repo.get('pat') as string || '').trim();
|
|
173
|
-
const repoUrl = (repo.get('repoUrl') as string || '').trim();
|
|
174
|
-
const username = (repo.get('username') as string || '').trim();
|
|
210
|
+
const pat = ((repo.get('pat') as string) || '').trim();
|
|
211
|
+
const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
|
|
212
|
+
const username = ((repo.get('username') as string) || '').trim();
|
|
175
213
|
|
|
176
214
|
const git = getGit(ctx, localPath);
|
|
177
215
|
const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
|
|
@@ -183,9 +221,9 @@ export async function pull(ctx: Context, next: () => Promise<void>) {
|
|
|
183
221
|
export async function push(ctx: Context, next: () => Promise<void>) {
|
|
184
222
|
const repo = await getRepo(ctx);
|
|
185
223
|
const localPath = validateLocalPath(repo.get('localPath'));
|
|
186
|
-
const pat = (repo.get('pat') as string || '').trim();
|
|
187
|
-
const repoUrl = (repo.get('repoUrl') as string || '').trim();
|
|
188
|
-
const username = (repo.get('username') as string || '').trim();
|
|
224
|
+
const pat = ((repo.get('pat') as string) || '').trim();
|
|
225
|
+
const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
|
|
226
|
+
const username = ((repo.get('username') as string) || '').trim();
|
|
189
227
|
|
|
190
228
|
const git = getGit(ctx, localPath);
|
|
191
229
|
const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
|
|
@@ -197,9 +235,9 @@ export async function push(ctx: Context, next: () => Promise<void>) {
|
|
|
197
235
|
export async function fetch(ctx: Context, next: () => Promise<void>) {
|
|
198
236
|
const repo = await getRepo(ctx);
|
|
199
237
|
const localPath = validateLocalPath(repo.get('localPath'));
|
|
200
|
-
const pat = (repo.get('pat') as string || '').trim();
|
|
201
|
-
const repoUrl = (repo.get('repoUrl') as string || '').trim();
|
|
202
|
-
const username = (repo.get('username') as string || '').trim();
|
|
238
|
+
const pat = ((repo.get('pat') as string) || '').trim();
|
|
239
|
+
const repoUrl = ((repo.get('repoUrl') as string) || '').trim();
|
|
240
|
+
const username = ((repo.get('username') as string) || '').trim();
|
|
203
241
|
|
|
204
242
|
const git = getGit(ctx, localPath);
|
|
205
243
|
const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
|
|
@@ -287,22 +325,28 @@ export async function fileTree(ctx: Context, next: () => Promise<void>) {
|
|
|
287
325
|
const detailArgs = ['ls-tree', '-l', ref];
|
|
288
326
|
if (treePath) detailArgs.push(treePath + '/');
|
|
289
327
|
const detailedResult = await git.raw(detailArgs);
|
|
290
|
-
const items = detailedResult
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
const items = detailedResult
|
|
329
|
+
.trim()
|
|
330
|
+
.split('\n')
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.map((line) => {
|
|
333
|
+
// format: <mode> <type> <hash> <size>\t<name>
|
|
334
|
+
const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\s+(-|\d+)\t(.+)$/);
|
|
335
|
+
if (!match) return null;
|
|
336
|
+
const fullPath = match[5];
|
|
337
|
+
// Extract just the filename from the full path when using treePath prefix
|
|
338
|
+
const parts = fullPath.split('/');
|
|
339
|
+
const name = fullPath.includes('/') ? parts[parts.length - 1] : fullPath;
|
|
340
|
+
return {
|
|
341
|
+
mode: match[1],
|
|
342
|
+
type: match[2] as 'blob' | 'tree',
|
|
343
|
+
hash: match[3],
|
|
344
|
+
size: match[4] === '-' ? 0 : parseInt(match[4], 10),
|
|
345
|
+
name,
|
|
346
|
+
path: treePath ? `${treePath}/${name}` : name,
|
|
347
|
+
};
|
|
348
|
+
})
|
|
349
|
+
.filter(Boolean);
|
|
306
350
|
|
|
307
351
|
// Sort: directories first, then files, both alphabetical
|
|
308
352
|
items.sort((a, b) => {
|
|
@@ -360,10 +404,14 @@ export async function commitDetail(ctx: Context, next: () => Promise<void>) {
|
|
|
360
404
|
]);
|
|
361
405
|
|
|
362
406
|
const parts = show.split(DELIM_OUT);
|
|
363
|
-
const files = diffResult
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
407
|
+
const files = diffResult
|
|
408
|
+
.trim()
|
|
409
|
+
.split('\n')
|
|
410
|
+
.filter(Boolean)
|
|
411
|
+
.map((line) => {
|
|
412
|
+
const [statusCode, ...fileParts] = line.split('\t');
|
|
413
|
+
return { status: statusCode, file: fileParts.join('\t') };
|
|
414
|
+
});
|
|
367
415
|
|
|
368
416
|
ctx.body = {
|
|
369
417
|
success: true,
|