@oomfware/cgr 0.1.1 → 0.1.3
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/README.md +19 -11
- package/dist/assets/system-prompt.md +60 -17
- package/dist/index.mjs +354 -119
- package/package.json +3 -1
- package/src/assets/system-prompt.md +60 -17
- package/src/commands/ask.ts +185 -55
- package/src/commands/clean.ts +152 -96
- package/src/lib/debug.ts +11 -0
- package/src/lib/git.ts +28 -4
- package/src/lib/paths.ts +98 -14
- package/src/lib/symlink.ts +49 -0
package/src/commands/clean.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline';
|
|
1
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
5
3
|
|
|
4
|
+
import checkbox from '@inquirer/checkbox';
|
|
5
|
+
import confirm from '@inquirer/confirm';
|
|
6
6
|
import { argument, constant, type InferValue, message, object, option, string } from '@optique/core';
|
|
7
7
|
import { optional } from '@optique/core/modifiers';
|
|
8
8
|
|
|
9
|
-
import { getRepoCachePath, getReposDir } from '../lib/paths.ts';
|
|
9
|
+
import { getRepoCachePath, getReposDir, getSessionsDir } from '../lib/paths.ts';
|
|
10
10
|
|
|
11
11
|
export const schema = object({
|
|
12
12
|
command: constant('clean'),
|
|
13
13
|
all: option('--all', { description: message`remove all cached repositories` }),
|
|
14
|
+
yes: option('-y', '--yes', { description: message`skip confirmation prompts` }),
|
|
14
15
|
remote: optional(
|
|
15
16
|
argument(string({ metavar: 'URL' }), {
|
|
16
17
|
description: message`specific remote URL to clean`,
|
|
@@ -20,6 +21,20 @@ export const schema = object({
|
|
|
20
21
|
|
|
21
22
|
export type Args = InferValue<typeof schema>;
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* checks if a path exists.
|
|
26
|
+
* @param path the path to check
|
|
27
|
+
* @returns true if the path exists
|
|
28
|
+
*/
|
|
29
|
+
const exists = async (path: string): Promise<boolean> => {
|
|
30
|
+
try {
|
|
31
|
+
await stat(path);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* formats a byte size in human-readable form.
|
|
25
40
|
* @param bytes size in bytes
|
|
@@ -43,16 +58,17 @@ const formatSize = (bytes: number): string => {
|
|
|
43
58
|
* @param dir directory path
|
|
44
59
|
* @returns total size in bytes
|
|
45
60
|
*/
|
|
46
|
-
const getDirSize = (dir: string): number => {
|
|
61
|
+
const getDirSize = async (dir: string): Promise<number> => {
|
|
47
62
|
let size = 0;
|
|
48
63
|
try {
|
|
49
|
-
const entries =
|
|
64
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
50
65
|
for (const entry of entries) {
|
|
51
66
|
const fullPath = join(dir, entry.name);
|
|
52
67
|
if (entry.isDirectory()) {
|
|
53
|
-
size += getDirSize(fullPath);
|
|
68
|
+
size += await getDirSize(fullPath);
|
|
54
69
|
} else {
|
|
55
|
-
|
|
70
|
+
const s = await stat(fullPath);
|
|
71
|
+
size += s.size;
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
74
|
} catch {
|
|
@@ -61,138 +77,178 @@ const getDirSize = (dir: string): number => {
|
|
|
61
77
|
return size;
|
|
62
78
|
};
|
|
63
79
|
|
|
80
|
+
/**
|
|
81
|
+
* recursively finds git repositories within a directory.
|
|
82
|
+
* yields directories that contain a .git subdirectory.
|
|
83
|
+
* @param dir directory to search
|
|
84
|
+
*/
|
|
85
|
+
async function* findRepos(dir: string): AsyncGenerator<string> {
|
|
86
|
+
try {
|
|
87
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
88
|
+
const hasGit = entries.some((e) => e.name === '.git' && e.isDirectory());
|
|
89
|
+
|
|
90
|
+
if (hasGit) {
|
|
91
|
+
yield dir;
|
|
92
|
+
} else {
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
yield* findRepos(join(dir, entry.name));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore errors (e.g., permission denied)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
64
104
|
/**
|
|
65
105
|
* lists all cached repositories with their sizes.
|
|
66
106
|
* @returns array of { path, displayPath, size } objects
|
|
67
107
|
*/
|
|
68
|
-
const listCachedRepos = (): {
|
|
69
|
-
path: string;
|
|
70
|
-
displayPath: string;
|
|
71
|
-
size: number;
|
|
72
|
-
}[] => {
|
|
108
|
+
const listCachedRepos = async (): Promise<{ path: string; displayPath: string; size: number }[]> => {
|
|
73
109
|
const reposDir = getReposDir();
|
|
74
|
-
const repos: { path: string; displayPath: string; size: number }[] = [];
|
|
75
110
|
|
|
76
|
-
if (!
|
|
77
|
-
return
|
|
111
|
+
if (!(await exists(reposDir))) {
|
|
112
|
+
return [];
|
|
78
113
|
}
|
|
79
114
|
|
|
80
|
-
|
|
81
|
-
for (const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// iterate owners
|
|
88
|
-
for (const owner of readdirSync(hostPath)) {
|
|
89
|
-
const ownerPath = join(hostPath, owner);
|
|
90
|
-
if (!statSync(ownerPath).isDirectory()) {
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// iterate repos
|
|
95
|
-
for (const repo of readdirSync(ownerPath)) {
|
|
96
|
-
const repoPath = join(ownerPath, repo);
|
|
97
|
-
if (!statSync(repoPath).isDirectory()) {
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
repos.push({
|
|
102
|
-
path: repoPath,
|
|
103
|
-
displayPath: `${host}/${owner}/${repo}`,
|
|
104
|
-
size: getDirSize(repoPath),
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
|
115
|
+
const repos: { path: string; displayPath: string; size: number }[] = [];
|
|
116
|
+
for await (const repoPath of findRepos(reposDir)) {
|
|
117
|
+
const displayPath = relative(reposDir, repoPath);
|
|
118
|
+
const size = await getDirSize(repoPath);
|
|
119
|
+
repos.push({ path: repoPath, displayPath, size });
|
|
108
120
|
}
|
|
109
|
-
|
|
110
121
|
return repos;
|
|
111
122
|
};
|
|
112
123
|
|
|
113
|
-
/**
|
|
114
|
-
* prompts the user for confirmation.
|
|
115
|
-
* @param msg the prompt message
|
|
116
|
-
* @returns promise that resolves to true if confirmed, or exits on interrupt
|
|
117
|
-
*/
|
|
118
|
-
const confirm = (msg: string): Promise<boolean> =>
|
|
119
|
-
new Promise((resolve) => {
|
|
120
|
-
let answered = false;
|
|
121
|
-
const rl = createInterface({
|
|
122
|
-
input: process.stdin,
|
|
123
|
-
output: process.stdout,
|
|
124
|
-
});
|
|
125
|
-
rl.on('close', () => {
|
|
126
|
-
if (!answered) {
|
|
127
|
-
// handle Ctrl+C or stream close
|
|
128
|
-
console.log();
|
|
129
|
-
process.exit(130);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
rl.question(`${msg} [y/N] `, (answer) => {
|
|
133
|
-
answered = true;
|
|
134
|
-
rl.close();
|
|
135
|
-
resolve(answer.toLowerCase() === 'y');
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
124
|
/**
|
|
140
125
|
* handles the clean command.
|
|
141
126
|
* @param args parsed command arguments
|
|
142
127
|
*/
|
|
143
128
|
export const handler = async (args: Args): Promise<void> => {
|
|
144
129
|
const reposDir = getReposDir();
|
|
130
|
+
const sessionsDir = getSessionsDir();
|
|
145
131
|
|
|
146
|
-
// clean all
|
|
132
|
+
// #region clean all
|
|
147
133
|
if (args.all) {
|
|
148
|
-
|
|
149
|
-
|
|
134
|
+
const reposExist = await exists(reposDir);
|
|
135
|
+
const sessionsExist = await exists(sessionsDir);
|
|
136
|
+
|
|
137
|
+
if (!reposExist && !sessionsExist) {
|
|
138
|
+
console.log('no cached data found');
|
|
150
139
|
return;
|
|
151
140
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
141
|
+
|
|
142
|
+
let totalSize = 0;
|
|
143
|
+
if (reposExist) {
|
|
144
|
+
totalSize += await getDirSize(reposDir);
|
|
145
|
+
}
|
|
146
|
+
if (sessionsExist) {
|
|
147
|
+
totalSize += await getDirSize(sessionsDir);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!args.yes) {
|
|
151
|
+
const confirmed = await confirm({
|
|
152
|
+
message: `remove all cached data? (${formatSize(totalSize)})`,
|
|
153
|
+
default: false,
|
|
154
|
+
});
|
|
155
|
+
if (!confirmed) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (reposExist) {
|
|
161
|
+
await rm(reposDir, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
if (sessionsExist) {
|
|
164
|
+
await rm(sessionsDir, { recursive: true });
|
|
165
|
+
}
|
|
155
166
|
console.log('done');
|
|
156
167
|
return;
|
|
157
168
|
}
|
|
169
|
+
// #endregion
|
|
158
170
|
|
|
159
|
-
// clean specific remote
|
|
171
|
+
// #region clean specific remote
|
|
160
172
|
if (args.remote) {
|
|
161
173
|
const cachePath = getRepoCachePath(args.remote);
|
|
162
174
|
if (!cachePath) {
|
|
163
175
|
console.error(`error: invalid remote URL: ${args.remote}`);
|
|
164
176
|
process.exit(1);
|
|
165
177
|
}
|
|
166
|
-
if (!
|
|
178
|
+
if (!(await exists(cachePath))) {
|
|
167
179
|
console.error(`error: repository not cached: ${args.remote}`);
|
|
168
180
|
process.exit(1);
|
|
169
181
|
}
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
|
|
183
|
+
const size = await getDirSize(cachePath);
|
|
184
|
+
|
|
185
|
+
if (!args.yes) {
|
|
186
|
+
const confirmed = await confirm({
|
|
187
|
+
message: `remove ${args.remote}? (${formatSize(size)})`,
|
|
188
|
+
default: false,
|
|
189
|
+
});
|
|
190
|
+
if (!confirmed) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
await rm(cachePath, { recursive: true });
|
|
173
196
|
console.log('done');
|
|
174
197
|
return;
|
|
175
198
|
}
|
|
199
|
+
// #endregion
|
|
176
200
|
|
|
177
|
-
//
|
|
178
|
-
const repos = listCachedRepos();
|
|
179
|
-
|
|
180
|
-
|
|
201
|
+
// #region interactive selection
|
|
202
|
+
const repos = await listCachedRepos();
|
|
203
|
+
const sessionsExist = await exists(sessionsDir);
|
|
204
|
+
const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
|
|
205
|
+
|
|
206
|
+
if (repos.length === 0 && !sessionsExist) {
|
|
207
|
+
console.log('no cached data found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// build choices for checkbox
|
|
212
|
+
const choices: { name: string; value: string }[] = repos.map((repo) => ({
|
|
213
|
+
name: `${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`,
|
|
214
|
+
value: repo.path,
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
if (sessionsExist && sessionsSize > 0) {
|
|
218
|
+
choices.push({
|
|
219
|
+
name: `${'(sessions)'.padEnd(50)} ${formatSize(sessionsSize)}`,
|
|
220
|
+
value: sessionsDir,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const selected = await checkbox({
|
|
225
|
+
message: 'select items to remove',
|
|
226
|
+
choices,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (selected.length === 0) {
|
|
181
230
|
return;
|
|
182
231
|
}
|
|
183
232
|
|
|
184
|
-
|
|
233
|
+
// calculate total size of selected items
|
|
185
234
|
let totalSize = 0;
|
|
186
|
-
for (const
|
|
187
|
-
|
|
188
|
-
totalSize += repo.size;
|
|
235
|
+
for (const path of selected) {
|
|
236
|
+
totalSize += await getDirSize(path);
|
|
189
237
|
}
|
|
190
|
-
console.log(`\n total: ${formatSize(totalSize)}`);
|
|
191
|
-
console.log();
|
|
192
238
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
239
|
+
if (!args.yes) {
|
|
240
|
+
const confirmed = await confirm({
|
|
241
|
+
message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
|
|
242
|
+
default: false,
|
|
243
|
+
});
|
|
244
|
+
if (!confirmed) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const path of selected) {
|
|
250
|
+
await rm(path, { recursive: true });
|
|
197
251
|
}
|
|
252
|
+
console.log('done');
|
|
253
|
+
// #endregion
|
|
198
254
|
};
|
package/src/lib/debug.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const debugEnabled = process.env.DEBUG === '1' || process.env.CGR_DEBUG === '1';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* logs a debug message to stderr if DEBUG=1 or CGR_DEBUG=1.
|
|
5
|
+
* @param message the message to log
|
|
6
|
+
*/
|
|
7
|
+
export const debug = (message: string): void => {
|
|
8
|
+
if (debugEnabled) {
|
|
9
|
+
console.error(`[debug] ${message}`);
|
|
10
|
+
}
|
|
11
|
+
};
|
package/src/lib/git.ts
CHANGED
|
@@ -3,22 +3,35 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir } from 'node:fs/promises';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { debug, debugEnabled } from './debug.ts';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
|
-
* executes a git command
|
|
9
|
+
* executes a git command silently, only showing output on failure.
|
|
10
|
+
* when debug is enabled, inherits stdio to show git progress.
|
|
8
11
|
* @param args git command arguments
|
|
9
12
|
* @param cwd working directory
|
|
10
13
|
* @returns promise that resolves when the command completes
|
|
11
14
|
*/
|
|
12
15
|
const git = (args: string[], cwd?: string): Promise<void> =>
|
|
13
16
|
new Promise((resolve, reject) => {
|
|
17
|
+
debug(`git ${args.join(' ')}${cwd ? ` (in ${cwd})` : ''}`);
|
|
14
18
|
const proc = spawn('git', args, {
|
|
15
19
|
cwd,
|
|
16
|
-
stdio: 'inherit',
|
|
20
|
+
stdio: debugEnabled ? 'inherit' : ['inherit', 'pipe', 'pipe'],
|
|
17
21
|
});
|
|
22
|
+
let stderr = '';
|
|
23
|
+
if (!debugEnabled) {
|
|
24
|
+
proc.stderr!.on('data', (data: Buffer) => {
|
|
25
|
+
stderr += data.toString();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
18
28
|
proc.on('close', (code) => {
|
|
19
29
|
if (code === 0) {
|
|
20
30
|
resolve();
|
|
21
31
|
} else {
|
|
32
|
+
if (stderr) {
|
|
33
|
+
process.stderr.write(stderr);
|
|
34
|
+
}
|
|
22
35
|
reject(new Error(`git ${args[0]} failed with code ${code}`));
|
|
23
36
|
}
|
|
24
37
|
});
|
|
@@ -26,25 +39,36 @@ const git = (args: string[], cwd?: string): Promise<void> =>
|
|
|
26
39
|
});
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
|
-
* executes a git command and captures stdout.
|
|
42
|
+
* executes a git command and captures stdout, only showing stderr on failure.
|
|
43
|
+
* when debug is enabled, inherits stderr to show git progress.
|
|
30
44
|
* @param args git command arguments
|
|
31
45
|
* @param cwd working directory
|
|
32
46
|
* @returns promise that resolves with stdout
|
|
33
47
|
*/
|
|
34
48
|
const gitOutput = (args: string[], cwd?: string): Promise<string> =>
|
|
35
49
|
new Promise((resolve, reject) => {
|
|
50
|
+
debug(`git ${args.join(' ')}${cwd ? ` (in ${cwd})` : ''}`);
|
|
36
51
|
const proc = spawn('git', args, {
|
|
37
52
|
cwd,
|
|
38
|
-
stdio: ['inherit', 'pipe', 'inherit'],
|
|
53
|
+
stdio: ['inherit', 'pipe', debugEnabled ? 'inherit' : 'pipe'],
|
|
39
54
|
});
|
|
40
55
|
let output = '';
|
|
56
|
+
let stderr = '';
|
|
41
57
|
proc.stdout!.on('data', (data: Buffer) => {
|
|
42
58
|
output += data.toString();
|
|
43
59
|
});
|
|
60
|
+
if (!debugEnabled) {
|
|
61
|
+
proc.stderr!.on('data', (data: Buffer) => {
|
|
62
|
+
stderr += data.toString();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
44
65
|
proc.on('close', (code) => {
|
|
45
66
|
if (code === 0) {
|
|
46
67
|
resolve(output.trim());
|
|
47
68
|
} else {
|
|
69
|
+
if (stderr) {
|
|
70
|
+
process.stderr.write(stderr);
|
|
71
|
+
}
|
|
48
72
|
reject(new Error(`git ${args[0]} failed with code ${code}`));
|
|
49
73
|
}
|
|
50
74
|
});
|
package/src/lib/paths.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
1
3
|
import { homedir } from 'node:os';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
|
|
@@ -26,46 +28,88 @@ export const getCacheDir = (): string => {
|
|
|
26
28
|
*/
|
|
27
29
|
export const getReposDir = (): string => join(getCacheDir(), 'repos');
|
|
28
30
|
|
|
31
|
+
// windows reserved names that cannot be used as filenames
|
|
32
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
33
|
+
|
|
34
|
+
// characters that are invalid in windows filenames
|
|
35
|
+
const UNSAFE_CHARS = /[<>:"|?*\\]/g;
|
|
36
|
+
|
|
29
37
|
/**
|
|
30
|
-
*
|
|
38
|
+
* sanitizes a path segment to be safe on all filesystems.
|
|
39
|
+
* encodes unsafe characters using percent-encoding and handles windows reserved names.
|
|
40
|
+
* @param segment the path segment to sanitize
|
|
41
|
+
* @returns sanitized segment
|
|
42
|
+
*/
|
|
43
|
+
const sanitizeSegment = (segment: string): string => {
|
|
44
|
+
let safe = segment
|
|
45
|
+
// encode percent first to avoid double-encoding
|
|
46
|
+
.replace(/%/g, '%25')
|
|
47
|
+
// encode windows-unsafe characters
|
|
48
|
+
.replace(UNSAFE_CHARS, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
49
|
+
// trim trailing dots and spaces (windows doesn't allow them)
|
|
50
|
+
.replace(/[. ]+$/, (m) =>
|
|
51
|
+
m
|
|
52
|
+
.split('')
|
|
53
|
+
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
54
|
+
.join(''),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// handle windows reserved names by appending an underscore
|
|
58
|
+
if (WINDOWS_RESERVED.test(safe)) {
|
|
59
|
+
safe = `${safe}_`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return safe;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* parses a git remote URL and extracts host and path.
|
|
31
67
|
* supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
|
|
32
68
|
* @param remote the remote URL to parse
|
|
33
69
|
* @returns parsed components or null if invalid
|
|
34
70
|
*/
|
|
35
|
-
export const parseRemote = (remote: string): { host: string;
|
|
36
|
-
// HTTP(S): https://github.com/user/repo or
|
|
37
|
-
const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(
|
|
71
|
+
export const parseRemote = (remote: string): { host: string; path: string } | null => {
|
|
72
|
+
// HTTP(S): https://github.com/user/repo[/more/paths] or ending with .git
|
|
73
|
+
const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
38
74
|
if (httpMatch) {
|
|
39
75
|
return {
|
|
40
76
|
host: httpMatch[1]!,
|
|
41
|
-
|
|
42
|
-
repo: httpMatch[3]!,
|
|
77
|
+
path: httpMatch[2]!,
|
|
43
78
|
};
|
|
44
79
|
}
|
|
45
80
|
|
|
46
|
-
// SSH: git@github.com:
|
|
47
|
-
const sshMatch = remote.match(/^git@([^:]+):(
|
|
81
|
+
// SSH: git@github.com:path/to/repo.git or git@github.com:path/to/repo
|
|
82
|
+
const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
48
83
|
if (sshMatch) {
|
|
49
84
|
return {
|
|
50
85
|
host: sshMatch[1]!,
|
|
51
|
-
|
|
52
|
-
repo: sshMatch[3]!,
|
|
86
|
+
path: sshMatch[2]!,
|
|
53
87
|
};
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
// bare URL: github.com/user/repo (assumes HTTPS)
|
|
57
|
-
const bareMatch = remote.match(/^([^/]+)\/(
|
|
91
|
+
const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
|
|
58
92
|
if (bareMatch) {
|
|
59
93
|
return {
|
|
60
94
|
host: bareMatch[1]!,
|
|
61
|
-
|
|
62
|
-
repo: bareMatch[3]!,
|
|
95
|
+
path: bareMatch[2]!,
|
|
63
96
|
};
|
|
64
97
|
}
|
|
65
98
|
|
|
66
99
|
return null;
|
|
67
100
|
};
|
|
68
101
|
|
|
102
|
+
/**
|
|
103
|
+
* sanitizes a parsed remote path for safe filesystem storage.
|
|
104
|
+
* @param parsed the parsed remote (host + path)
|
|
105
|
+
* @returns sanitized path segments joined with the system separator
|
|
106
|
+
*/
|
|
107
|
+
export const sanitizeRemotePath = (parsed: { host: string; path: string }): string => {
|
|
108
|
+
const host = sanitizeSegment(parsed.host.toLowerCase());
|
|
109
|
+
const pathSegments = parsed.path.toLowerCase().split('/').map(sanitizeSegment);
|
|
110
|
+
return join(host, ...pathSegments);
|
|
111
|
+
};
|
|
112
|
+
|
|
69
113
|
/**
|
|
70
114
|
* normalizes a remote URL by prepending https:// if no protocol is present.
|
|
71
115
|
* @param remote the remote URL to normalize
|
|
@@ -88,5 +132,45 @@ export const getRepoCachePath = (remote: string): string | null => {
|
|
|
88
132
|
if (!parsed) {
|
|
89
133
|
return null;
|
|
90
134
|
}
|
|
91
|
-
return join(getReposDir(), parsed
|
|
135
|
+
return join(getReposDir(), sanitizeRemotePath(parsed));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* parses `remote#branch` syntax.
|
|
140
|
+
* @param input the input string, e.g. `github.com/owner/repo#develop`
|
|
141
|
+
* @returns object with remote and optional branch
|
|
142
|
+
*/
|
|
143
|
+
export const parseRemoteWithBranch = (input: string): { remote: string; branch?: string } => {
|
|
144
|
+
const hashIndex = input.lastIndexOf('#');
|
|
145
|
+
if (hashIndex === -1) {
|
|
146
|
+
return { remote: input };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
remote: input.slice(0, hashIndex),
|
|
150
|
+
branch: input.slice(hashIndex + 1),
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* returns the sessions directory within the cache.
|
|
156
|
+
* @returns the sessions directory path
|
|
157
|
+
*/
|
|
158
|
+
export const getSessionsDir = (): string => join(getCacheDir(), 'sessions');
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* creates a new session directory with a random UUID.
|
|
162
|
+
* @returns the path to the created session directory
|
|
163
|
+
*/
|
|
164
|
+
export const createSessionDir = async (): Promise<string> => {
|
|
165
|
+
const sessionPath = join(getSessionsDir(), randomUUID());
|
|
166
|
+
await mkdir(sessionPath, { recursive: true });
|
|
167
|
+
return sessionPath;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* removes a session directory.
|
|
172
|
+
* @param sessionPath the session directory path
|
|
173
|
+
*/
|
|
174
|
+
export const cleanupSessionDir = async (sessionPath: string): Promise<void> => {
|
|
175
|
+
await rm(sessionPath, { recursive: true, force: true });
|
|
92
176
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { symlink } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** parsed remote information */
|
|
5
|
+
export type ParsedRemote = { host: string; path: string };
|
|
6
|
+
|
|
7
|
+
/** repository entry with all metadata needed for symlinking */
|
|
8
|
+
export type RepoEntry = {
|
|
9
|
+
remote: string;
|
|
10
|
+
parsed: ParsedRemote;
|
|
11
|
+
cachePath: string;
|
|
12
|
+
branch?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* builds symlinks in a session directory for all repositories.
|
|
17
|
+
* handles name conflicts by appending `-2`, `-3`, etc.
|
|
18
|
+
* @param sessionPath the session directory path
|
|
19
|
+
* @param repos array of repository entries to symlink
|
|
20
|
+
* @returns map of directory name -> repo entry
|
|
21
|
+
*/
|
|
22
|
+
export const buildSymlinkDir = async (
|
|
23
|
+
sessionPath: string,
|
|
24
|
+
repos: RepoEntry[],
|
|
25
|
+
): Promise<Map<string, RepoEntry>> => {
|
|
26
|
+
const result = new Map<string, RepoEntry>();
|
|
27
|
+
const usedNames = new Set<string>();
|
|
28
|
+
|
|
29
|
+
for (const repo of repos) {
|
|
30
|
+
// use the last component of the path as the directory name
|
|
31
|
+
const repoName = basename(repo.parsed.path);
|
|
32
|
+
let name = repoName;
|
|
33
|
+
let suffix = 1;
|
|
34
|
+
|
|
35
|
+
// handle name conflicts
|
|
36
|
+
while (usedNames.has(name)) {
|
|
37
|
+
suffix++;
|
|
38
|
+
name = `${repoName}-${suffix}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
usedNames.add(name);
|
|
42
|
+
result.set(name, repo);
|
|
43
|
+
|
|
44
|
+
const linkPath = join(sessionPath, name);
|
|
45
|
+
await symlink(repo.cachePath, linkPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
};
|