@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.
@@ -1,16 +1,17 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { rm } from 'node:fs/promises';
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 = readdirSync(dir, { withFileTypes: true });
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
- size += statSync(fullPath).size;
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 (!existsSync(reposDir)) {
77
- return repos;
111
+ if (!(await exists(reposDir))) {
112
+ return [];
78
113
  }
79
114
 
80
- // iterate hosts
81
- for (const host of readdirSync(reposDir)) {
82
- const hostPath = join(reposDir, host);
83
- if (!statSync(hostPath).isDirectory()) {
84
- continue;
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
- if (!existsSync(reposDir)) {
149
- console.log('no cached repositories found');
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
- const size = getDirSize(reposDir);
153
- console.log(`removing all cached repositories (${formatSize(size)})`);
154
- await rm(reposDir, { recursive: true });
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 (!existsSync(cachePath)) {
178
+ if (!(await exists(cachePath))) {
167
179
  console.error(`error: repository not cached: ${args.remote}`);
168
180
  process.exit(1);
169
181
  }
170
- const size = getDirSize(cachePath);
171
- console.log(`removing ${cachePath} (${formatSize(size)})`);
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
- // list repos and prompt for confirmation
178
- const repos = listCachedRepos();
179
- if (repos.length === 0) {
180
- console.log('no cached repositories found');
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
- console.log('cached repositories:\n');
233
+ // calculate total size of selected items
185
234
  let totalSize = 0;
186
- for (const repo of repos) {
187
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
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
- const confirmed = await confirm('remove all cached repositories?');
194
- if (confirmed) {
195
- await rm(reposDir, { recursive: true });
196
- console.log('done');
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
  };
@@ -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 and returns the result.
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
- * parses a git remote URL and extracts host, owner, and repo.
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; owner: string; repo: string } | null => {
36
- // HTTP(S): https://github.com/user/repo or https://github.com/user/repo.git
37
- const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
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
- owner: httpMatch[2]!,
42
- repo: httpMatch[3]!,
77
+ path: httpMatch[2]!,
43
78
  };
44
79
  }
45
80
 
46
- // SSH: git@github.com:user/repo.git or git@github.com:user/repo
47
- const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.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
- owner: sshMatch[2]!,
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(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
91
+ const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
58
92
  if (bareMatch) {
59
93
  return {
60
94
  host: bareMatch[1]!,
61
- owner: bareMatch[2]!,
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.host, parsed.owner, parsed.repo);
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
+ };