plugin-git-manager 1.2.0 → 1.2.2

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.0",
6
+ "version": "1.2.2",
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: () => void;
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: 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(git: ReturnType<typeof simpleGit>, localPath: string, repoUrl: string, pat: string, fn: () => Promise<any>, username?: string) {
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
- `Manual cleanup of .git/config may be required.`,
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
- return simpleGit(localPath);
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 simpleGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
185
+ await createGit().clone(authUrl, localPath, ['--branch', defaultBranch]);
151
186
  // Remove PAT from the cloned repo's remote URL
152
- await simpleGit(localPath).remote(['set-url', 'origin', repoUrl]);
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.trim().split('\n').filter(Boolean).map((line) => {
291
- // format: <mode> <type> <hash> <size>\t<name>
292
- const match = line.match(/^(\d+)\s+(blob|tree)\s+([a-f0-9]+)\s+(-|\d+)\t(.+)$/);
293
- if (!match) return null;
294
- const fullPath = match[5];
295
- // Extract just the filename from the full path when using treePath prefix
296
- const name = fullPath.includes('/') ? fullPath.split('/').pop()! : fullPath;
297
- return {
298
- mode: match[1],
299
- type: match[2] as 'blob' | 'tree',
300
- hash: match[3],
301
- size: match[4] === '-' ? 0 : parseInt(match[4], 10),
302
- name,
303
- path: treePath ? `${treePath}/${name}` : name,
304
- };
305
- }).filter(Boolean);
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.trim().split('\n').filter(Boolean).map((line) => {
364
- const [statusCode, ...fileParts] = line.split('\t');
365
- return { status: statusCode, file: fileParts.join('\t') };
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,