simplemdg-dev-cli 2.0.4 → 2.4.5

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 (148) hide show
  1. package/README.md +62 -354
  2. package/USER_GUIDE.md +55 -376
  3. package/dist/commands/cds.command.js +69 -60
  4. package/dist/commands/cds.command.js.map +1 -1
  5. package/dist/commands/cf-db.command.d.ts +2 -0
  6. package/dist/commands/cf-db.command.js +606 -0
  7. package/dist/commands/cf-db.command.js.map +1 -0
  8. package/dist/commands/cf.command.js +291 -280
  9. package/dist/commands/cf.command.js.map +1 -1
  10. package/dist/commands/gitlab.command.d.ts +2 -0
  11. package/dist/commands/gitlab.command.js +351 -0
  12. package/dist/commands/gitlab.command.js.map +1 -0
  13. package/dist/commands/npmrc.command.js +50 -44
  14. package/dist/commands/npmrc.command.js.map +1 -1
  15. package/dist/core/cache.d.ts +1 -1
  16. package/dist/core/cache.js +58 -31
  17. package/dist/core/cache.js.map +1 -1
  18. package/dist/core/cds.js +32 -22
  19. package/dist/core/cds.js.map +1 -1
  20. package/dist/core/cf-env-parser.d.ts +1 -1
  21. package/dist/core/cf-env-parser.js +4 -1
  22. package/dist/core/cf-env-parser.js.map +1 -1
  23. package/dist/core/cf.d.ts +1 -1
  24. package/dist/core/cf.js +46 -31
  25. package/dist/core/cf.js.map +1 -1
  26. package/dist/core/db/db-btp.d.ts +48 -0
  27. package/dist/core/db/db-btp.js +162 -0
  28. package/dist/core/db/db-btp.js.map +1 -0
  29. package/dist/core/db/db-cache.d.ts +40 -0
  30. package/dist/core/db/db-cache.js +188 -0
  31. package/dist/core/db/db-cache.js.map +1 -0
  32. package/dist/core/db/db-connection.d.ts +22 -0
  33. package/dist/core/db/db-connection.js +73 -0
  34. package/dist/core/db/db-connection.js.map +1 -0
  35. package/dist/core/db/db-crypto.d.ts +3 -0
  36. package/dist/core/db/db-crypto.js +54 -0
  37. package/dist/core/db/db-crypto.js.map +1 -0
  38. package/dist/core/db/db-hana-adapter.d.ts +36 -0
  39. package/dist/core/db/db-hana-adapter.js +251 -0
  40. package/dist/core/db/db-hana-adapter.js.map +1 -0
  41. package/dist/core/db/db-metadata.d.ts +25 -0
  42. package/dist/core/db/db-metadata.js +150 -0
  43. package/dist/core/db/db-metadata.js.map +1 -0
  44. package/dist/core/db/db-postgres-adapter.d.ts +34 -0
  45. package/dist/core/db/db-postgres-adapter.js +259 -0
  46. package/dist/core/db/db-postgres-adapter.js.map +1 -0
  47. package/dist/core/db/db-query-files.d.ts +20 -0
  48. package/dist/core/db/db-query-files.js +106 -0
  49. package/dist/core/db/db-query-files.js.map +1 -0
  50. package/dist/core/db/db-query-history.d.ts +5 -0
  51. package/dist/core/db/db-query-history.js +49 -0
  52. package/dist/core/db/db-query-history.js.map +1 -0
  53. package/dist/core/db/db-row.d.ts +28 -0
  54. package/dist/core/db/db-row.js +123 -0
  55. package/dist/core/db/db-row.js.map +1 -0
  56. package/dist/core/db/db-studio-client.d.ts +1 -0
  57. package/dist/core/db/db-studio-client.js +401 -0
  58. package/dist/core/db/db-studio-client.js.map +1 -0
  59. package/dist/core/db/db-studio-html.d.ts +4 -0
  60. package/dist/core/db/db-studio-html.js +83 -0
  61. package/dist/core/db/db-studio-html.js.map +1 -0
  62. package/dist/core/db/db-studio-server.d.ts +11 -0
  63. package/dist/core/db/db-studio-server.js +528 -0
  64. package/dist/core/db/db-studio-server.js.map +1 -0
  65. package/dist/core/db/db-studio-styles.d.ts +1 -0
  66. package/dist/core/db/db-studio-styles.js +225 -0
  67. package/dist/core/db/db-studio-styles.js.map +1 -0
  68. package/dist/core/db/db-types.d.ts +214 -0
  69. package/dist/core/db/db-types.js +3 -0
  70. package/dist/core/db/db-types.js.map +1 -0
  71. package/dist/core/db/db-vcap-parser.d.ts +7 -0
  72. package/dist/core/db/db-vcap-parser.js +137 -0
  73. package/dist/core/db/db-vcap-parser.js.map +1 -0
  74. package/dist/core/doctor.d.ts +1 -1
  75. package/dist/core/doctor.js +14 -8
  76. package/dist/core/doctor.js.map +1 -1
  77. package/dist/core/guide.js +31 -26
  78. package/dist/core/guide.js.map +1 -1
  79. package/dist/core/install.d.ts +1 -1
  80. package/dist/core/install.js +17 -11
  81. package/dist/core/install.js.map +1 -1
  82. package/dist/core/navigator.d.ts +17 -0
  83. package/dist/core/navigator.js +140 -0
  84. package/dist/core/navigator.js.map +1 -0
  85. package/dist/core/npmrc.js +29 -16
  86. package/dist/core/npmrc.js.map +1 -1
  87. package/dist/core/process.js +11 -6
  88. package/dist/core/process.js.map +1 -1
  89. package/dist/core/prompts.js +16 -8
  90. package/dist/core/prompts.js.map +1 -1
  91. package/dist/core/repository.d.ts +1 -1
  92. package/dist/core/repository.js +16 -9
  93. package/dist/core/repository.js.map +1 -1
  94. package/dist/core/scanner.d.ts +1 -1
  95. package/dist/core/scanner.js +13 -7
  96. package/dist/core/scanner.js.map +1 -1
  97. package/dist/core/tooling.d.ts +28 -0
  98. package/dist/core/tooling.js +168 -0
  99. package/dist/core/tooling.js.map +1 -0
  100. package/dist/core/types.js +2 -1
  101. package/dist/core/version-conflict.d.ts +2 -2
  102. package/dist/core/version-conflict.js +11 -6
  103. package/dist/core/version-conflict.js.map +1 -1
  104. package/dist/index.js +65 -48
  105. package/dist/index.js.map +1 -1
  106. package/dist/types-local.js +2 -1
  107. package/package.json +12 -6
  108. package/src/commands/cds.command.ts +529 -0
  109. package/src/commands/cf-db.command.ts +636 -0
  110. package/src/commands/cf.command.ts +3345 -0
  111. package/src/commands/gitlab.command.ts +373 -0
  112. package/src/commands/npmrc.command.ts +581 -0
  113. package/src/core/cache.ts +332 -0
  114. package/src/core/cds.ts +278 -0
  115. package/src/core/cf-env-parser.ts +131 -0
  116. package/src/core/cf.ts +271 -0
  117. package/src/core/db/db-btp.ts +207 -0
  118. package/src/core/db/db-cache.ts +242 -0
  119. package/src/core/db/db-connection.ts +79 -0
  120. package/src/core/db/db-crypto.ts +53 -0
  121. package/src/core/db/db-hana-adapter.ts +306 -0
  122. package/src/core/db/db-metadata.ts +174 -0
  123. package/src/core/db/db-postgres-adapter.ts +293 -0
  124. package/src/core/db/db-query-files.ts +130 -0
  125. package/src/core/db/db-query-history.ts +53 -0
  126. package/src/core/db/db-row.ts +157 -0
  127. package/src/core/db/db-studio-client.ts +397 -0
  128. package/src/core/db/db-studio-html.ts +85 -0
  129. package/src/core/db/db-studio-server.ts +626 -0
  130. package/src/core/db/db-studio-styles.ts +221 -0
  131. package/src/core/db/db-types.ts +243 -0
  132. package/src/core/db/db-vcap-parser.ts +182 -0
  133. package/src/core/doctor.ts +70 -0
  134. package/src/core/guide.ts +261 -0
  135. package/src/core/install.ts +91 -0
  136. package/src/core/navigator.ts +164 -0
  137. package/src/core/npmrc.ts +171 -0
  138. package/src/core/process.ts +75 -0
  139. package/src/core/prompts.ts +225 -0
  140. package/src/core/repository.ts +36 -0
  141. package/src/core/scanner.ts +41 -0
  142. package/src/core/tooling.ts +207 -0
  143. package/src/core/types.ts +152 -0
  144. package/src/core/version-conflict.ts +46 -0
  145. package/src/index.ts +460 -0
  146. package/src/types/external.d.ts +3 -0
  147. package/src/types-local.ts +11 -0
  148. package/tsconfig.json +17 -0
@@ -0,0 +1,373 @@
1
+ import fs from "fs-extra";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { execa } from "execa";
6
+ import prompts from "prompts";
7
+ import chalk from "chalk";
8
+ import { Command } from "commander";
9
+ import { searchableSelectChoice } from "../core/prompts";
10
+
11
+ const GITLAB_CACHE_DIR = path.join(os.homedir(), ".simplemdg");
12
+ const GITLAB_CACHE_FILE = path.join(GITLAB_CACHE_DIR, "gitlab.json");
13
+
14
+ type TGitLabAuth = {
15
+ baseUrl: string;
16
+ token: string;
17
+ username?: string;
18
+ name?: string;
19
+ expiresAt?: string | null;
20
+ updatedAt: string;
21
+ };
22
+
23
+ type TGitLabCache = {
24
+ instances: TGitLabAuth[];
25
+ groupsByBaseUrl: Record<string, { updatedAt: string; groups: TGitLabGroup[] }>;
26
+ projectsByGroup: Record<string, { updatedAt: string; projects: TGitLabProject[] }>;
27
+ destinations: string[];
28
+ };
29
+
30
+ type TGitLabGroup = {
31
+ id: number;
32
+ name: string;
33
+ full_path: string;
34
+ visibility?: string;
35
+ parent_id?: number | null;
36
+ };
37
+
38
+ type TGitLabProject = {
39
+ id: number;
40
+ name: string;
41
+ path_with_namespace: string;
42
+ http_url_to_repo: string;
43
+ ssh_url_to_repo?: string;
44
+ default_branch?: string;
45
+ archived?: boolean;
46
+ };
47
+
48
+ function emptyGitLabCache(): TGitLabCache {
49
+ return { instances: [], groupsByBaseUrl: {}, projectsByGroup: {}, destinations: ["."] };
50
+ }
51
+
52
+ async function readGitLabCache(): Promise<TGitLabCache> {
53
+ if (!(await fs.pathExists(GITLAB_CACHE_FILE))) return emptyGitLabCache();
54
+ const value = await fs.readJson(GITLAB_CACHE_FILE).catch(() => emptyGitLabCache()) as Partial<TGitLabCache>;
55
+ return {
56
+ instances: value.instances ?? [],
57
+ groupsByBaseUrl: value.groupsByBaseUrl ?? {},
58
+ projectsByGroup: value.projectsByGroup ?? {},
59
+ destinations: value.destinations?.length ? value.destinations : ["."],
60
+ };
61
+ }
62
+
63
+ async function writeGitLabCache(cache: TGitLabCache): Promise<void> {
64
+ await fs.ensureDir(GITLAB_CACHE_DIR);
65
+ await fs.writeJson(GITLAB_CACHE_FILE, cache, { spaces: 2 });
66
+ }
67
+
68
+ function normalizeBaseUrl(value: string): string {
69
+ return value.trim().replace(/\/+$/, "");
70
+ }
71
+
72
+ function makeGroupCacheKey(baseUrl: string, groupId: number): string {
73
+ return `${normalizeBaseUrl(baseUrl)}|${groupId}`;
74
+ }
75
+
76
+ async function openBrowser(url: string): Promise<void> {
77
+ const platform = process.platform;
78
+ const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
79
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
80
+ await execa(command, args, { reject: false, detached: true, stdio: "ignore" });
81
+ }
82
+
83
+ async function readClipboard(): Promise<string | undefined> {
84
+ if (process.platform !== "win32") return undefined;
85
+ const result = await execa("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { reject: false });
86
+ return result.exitCode === 0 ? result.stdout.trim() : undefined;
87
+ }
88
+
89
+ async function gitlabFetch<T>(auth: TGitLabAuth, apiPath: string, search?: URLSearchParams): Promise<T> {
90
+ const url = new URL(`${normalizeBaseUrl(auth.baseUrl)}/api/v4${apiPath}`);
91
+ if (search) {
92
+ for (const [key, value] of search.entries()) url.searchParams.set(key, value);
93
+ }
94
+ const response = await fetch(url, { headers: { "PRIVATE-TOKEN": auth.token } });
95
+ if (!response.ok) throw new Error(`GitLab API failed ${response.status}: ${await response.text()}`);
96
+ return await response.json() as T;
97
+ }
98
+
99
+ async function gitlabFetchAll<T>(auth: TGitLabAuth, apiPath: string, search?: Record<string, string>): Promise<T[]> {
100
+ const rows: T[] = [];
101
+ let page = 1;
102
+ while (true) {
103
+ const params = new URLSearchParams({ per_page: "100", page: String(page), ...(search ?? {}) });
104
+ const chunk = await gitlabFetch<T[]>(auth, apiPath, params);
105
+ rows.push(...chunk);
106
+ if (chunk.length < 100) break;
107
+ page += 1;
108
+ }
109
+ return rows;
110
+ }
111
+
112
+ async function validateToken(baseUrl: string, token: string): Promise<TGitLabAuth> {
113
+ const auth: TGitLabAuth = { baseUrl: normalizeBaseUrl(baseUrl), token, updatedAt: new Date().toISOString() };
114
+ const user = await gitlabFetch<{ username?: string; name?: string; email?: string }>(auth, "/user");
115
+ let expiresAt: string | null | undefined;
116
+ try {
117
+ const self = await gitlabFetch<{ expires_at?: string | null }>(auth, "/personal_access_tokens/self");
118
+ expiresAt = self.expires_at;
119
+ } catch {
120
+ expiresAt = undefined;
121
+ }
122
+ return { ...auth, username: user.username, name: user.name, expiresAt };
123
+ }
124
+
125
+ async function saveAuth(auth: TGitLabAuth): Promise<void> {
126
+ const cache = await readGitLabCache();
127
+ const next = [auth, ...cache.instances.filter((item) => normalizeBaseUrl(item.baseUrl) !== normalizeBaseUrl(auth.baseUrl))];
128
+ cache.instances = next.slice(0, 20);
129
+ await writeGitLabCache(cache);
130
+ await approveGitCredential(auth).catch(() => undefined);
131
+ }
132
+
133
+ async function approveGitCredential(auth: TGitLabAuth): Promise<void> {
134
+ const url = new URL(auth.baseUrl);
135
+ const input = [`protocol=${url.protocol.replace(":", "")}`, `host=${url.host}`, "username=oauth2", `password=${auth.token}`, "", ""].join("\n");
136
+ await execa("git", ["credential", "approve"], { input, reject: false });
137
+ }
138
+
139
+ async function askAuth(): Promise<TGitLabAuth> {
140
+ const cache = await readGitLabCache();
141
+ if (cache.instances.length) {
142
+ const selected = await searchableSelectChoice({
143
+ message: "Select GitLab instance",
144
+ choices: [
145
+ ...cache.instances.map((item, index) => ({ title: `${item.username ?? "user"} · ${item.baseUrl} · logged in`, value: String(index) })),
146
+ { title: "Login to another GitLab instance", value: "new" },
147
+ ],
148
+ allowCustomValue: false,
149
+ });
150
+ if (selected !== "new") return cache.instances[Number(selected)];
151
+ }
152
+ return await runLoginFlow();
153
+ }
154
+
155
+ async function runLoginFlow(): Promise<TGitLabAuth> {
156
+ const baseResponse = await prompts({ type: "text", name: "baseUrl", message: "GitLab base URL", initial: "https://gitlab.simplemdg.com" });
157
+ const baseUrl = normalizeBaseUrl(baseResponse.baseUrl || "https://gitlab.simplemdg.com");
158
+ const mode = await searchableSelectChoice({
159
+ message: "GitLab login method",
160
+ choices: [
161
+ { title: "Open token page and auto-detect from clipboard", value: "clipboard" },
162
+ { title: "Paste token manually", value: "manual" },
163
+ ],
164
+ allowCustomValue: false,
165
+ });
166
+ let token = "";
167
+ if (mode === "clipboard") {
168
+ const tokenUrl = `${baseUrl}/-/user_settings/personal_access_tokens?name=SimpleMDG%20CLI&scopes=api,read_repository,write_repository`;
169
+ console.log(chalk.gray(`Opening: ${tokenUrl}`));
170
+ await openBrowser(tokenUrl);
171
+ console.log(chalk.yellow("Create/copy the token in GitLab. The CLI will read clipboard, then fallback to manual input."));
172
+ for (let i = 0; i < 60; i += 1) {
173
+ const value = await readClipboard();
174
+ if (value && /^(glpat-|gloas-|glcbt-|[A-Za-z0-9_\-]{20,})/.test(value)) { token = value; break; }
175
+ await new Promise((resolve) => setTimeout(resolve, 1000));
176
+ }
177
+ }
178
+ if (!token) {
179
+ const response = await prompts({ type: "password", name: "token", message: "GitLab token" });
180
+ token = String(response.token ?? "").trim();
181
+ }
182
+ if (!token) throw new Error("GitLab token is required");
183
+ const auth = await validateToken(baseUrl, token);
184
+ await saveAuth(auth);
185
+ console.log(chalk.green(`Logged in: ${auth.username ?? auth.name ?? "GitLab user"} · ${auth.baseUrl}`));
186
+ return auth;
187
+ }
188
+
189
+ async function listRootGroups(auth: TGitLabAuth, refresh: boolean): Promise<TGitLabGroup[]> {
190
+ const cache = await readGitLabCache();
191
+ const cached = cache.groupsByBaseUrl[auth.baseUrl];
192
+ if (!refresh && cached?.groups?.length) return cached.groups;
193
+ console.log(chalk.gray(`Scanning GitLab root groups from ${auth.baseUrl}...`));
194
+ const groups = await gitlabFetchAll<TGitLabGroup>(auth, "/groups", { min_access_level: "10", top_level_only: "true", all_available: "false", order_by: "name", sort: "asc" });
195
+ cache.groupsByBaseUrl[auth.baseUrl] = { updatedAt: new Date().toISOString(), groups };
196
+ await writeGitLabCache(cache);
197
+ return groups;
198
+ }
199
+
200
+ async function askGroup(auth: TGitLabAuth, refresh?: boolean): Promise<TGitLabGroup> {
201
+ const groups = await listRootGroups(auth, !!refresh);
202
+ if (!groups.length) throw new Error("No GitLab root groups found for this account.");
203
+ const selected = await searchableSelectChoice({
204
+ message: "Search/select GitLab root group",
205
+ choices: groups.map((group) => ({ title: `${group.full_path} · #${group.id} · ${group.visibility ?? ""}`, value: String(group.id) })),
206
+ allowCustomValue: false,
207
+ });
208
+ const group = groups.find((item) => String(item.id) === selected);
209
+ if (!group) throw new Error("Group not found");
210
+ return group;
211
+ }
212
+
213
+ async function listProjects(auth: TGitLabAuth, group: TGitLabGroup, refresh: boolean): Promise<TGitLabProject[]> {
214
+ const cache = await readGitLabCache();
215
+ const key = makeGroupCacheKey(auth.baseUrl, group.id);
216
+ const cached = cache.projectsByGroup[key];
217
+ if (!refresh && cached?.projects?.length) return cached.projects;
218
+ console.log(chalk.gray(`Scanning projects in ${group.full_path}...`));
219
+ const encodedId = encodeURIComponent(String(group.id));
220
+ const projects = await gitlabFetchAll<TGitLabProject>(auth, `/groups/${encodedId}/projects`, { include_subgroups: "true", archived: "false", order_by: "path", sort: "asc" });
221
+ cache.projectsByGroup[key] = { updatedAt: new Date().toISOString(), projects };
222
+ await writeGitLabCache(cache);
223
+ return projects;
224
+ }
225
+
226
+ function localProjectPath(destination: string, project: TGitLabProject): string {
227
+ return path.resolve(destination, project.path_with_namespace.replace(/\//g, path.sep));
228
+ }
229
+
230
+ function gitEnv(auth: TGitLabAuth): NodeJS.ProcessEnv {
231
+ const askPass = path.join(os.tmpdir(), `smdg-git-askpass-${crypto.randomBytes(6).toString("hex")}${process.platform === "win32" ? ".cmd" : ".sh"}`);
232
+ if (process.platform === "win32") {
233
+ fs.writeFileSync(askPass, `@echo off\r\necho %SMDG_GIT_ASKPASS_VALUE%\r\n`);
234
+ } else {
235
+ fs.writeFileSync(askPass, `#!/bin/sh\nprintf '%s\\n' "$SMDG_GIT_ASKPASS_VALUE"\n`);
236
+ fs.chmodSync(askPass, 0o700);
237
+ }
238
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: askPass, SMDG_GIT_ASKPASS_VALUE: auth.token };
239
+ }
240
+
241
+ async function runGit(repoPath: string | undefined, args: string[], auth: TGitLabAuth): Promise<{ ok: boolean; output: string }> {
242
+ const result = await execa("git", args, { cwd: repoPath, reject: false, env: gitEnv(auth) });
243
+ return { ok: (result.exitCode ?? 0) === 0, output: [result.stdout, result.stderr].filter(Boolean).join("\n") };
244
+ }
245
+
246
+ async function cloneOrUpdateProject(project: TGitLabProject, destination: string, action: string, auth: TGitLabAuth): Promise<void> {
247
+ const repoPath = localProjectPath(destination, project);
248
+ await fs.ensureDir(path.dirname(repoPath));
249
+ if (!(await fs.pathExists(path.join(repoPath, ".git")))) {
250
+ const clone = await runGit(undefined, ["clone", project.http_url_to_repo, repoPath], auth);
251
+ if (!clone.ok) throw new Error(clone.output);
252
+ } else {
253
+ await runGit(repoPath, ["fetch", "--all", "--prune", "--tags"], auth);
254
+ }
255
+
256
+ if (action === "fetch") return;
257
+ if (action === "pull-current") {
258
+ const pull = await runGit(repoPath, ["pull", "--ff-only"], auth);
259
+ if (!pull.ok) throw new Error(pull.output);
260
+ return;
261
+ }
262
+
263
+ if (action === "pull-all") {
264
+ const original = await runGit(repoPath, ["branch", "--show-current"], auth);
265
+ const refs = await runGit(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"], auth);
266
+ if (!refs.ok) throw new Error(refs.output);
267
+ const branches = refs.output.split(/\r?\n/)
268
+ .map((line) => line.trim())
269
+ .filter((line) => line.startsWith("origin/"))
270
+ .map((line) => line.replace(/^origin\//, ""))
271
+ .filter((name) => name && !["HEAD", "origin"].includes(name));
272
+ for (const branch of branches) {
273
+ const localExists = await runGit(repoPath, ["show-ref", "--verify", `refs/heads/${branch}`], auth);
274
+ const switchArgs = localExists.ok ? ["switch", branch] : ["switch", "-c", branch, "--track", `origin/${branch}`];
275
+ const sw = await runGit(repoPath, switchArgs, auth);
276
+ if (sw.ok) await runGit(repoPath, ["pull", "--ff-only"], auth);
277
+ }
278
+ const restore = original.output.trim() || project.default_branch || "main";
279
+ await runGit(repoPath, ["switch", restore], auth);
280
+ }
281
+ }
282
+
283
+ async function parallelRun<T>(items: T[], concurrency: number, worker: (item: T, index: number) => Promise<void>): Promise<void> {
284
+ let cursor = 0;
285
+ let done = 0;
286
+ let failed = 0;
287
+ async function next(): Promise<void> {
288
+ while (cursor < items.length) {
289
+ const index = cursor++;
290
+ try { await worker(items[index], index); }
291
+ catch (error) { failed += 1; console.error(chalk.red(`[${index + 1}/${items.length}] FAIL ${error instanceof Error ? error.message : error}`)); }
292
+ finally { done += 1; process.stdout.write(chalk.gray(`\rProgress ${done}/${items.length} · failed ${failed}`)); }
293
+ }
294
+ }
295
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => next()));
296
+ process.stdout.write("\n");
297
+ }
298
+
299
+ async function runSync(options: { refresh?: boolean }): Promise<void> {
300
+ const auth = await askAuth();
301
+ const mode = await searchableSelectChoice({
302
+ message: "What do you want to pull/clone?",
303
+ choices: [
304
+ { title: "Pull/clone a GitLab group", value: "group" },
305
+ { title: "Pull/clone a single repository", value: "repo" },
306
+ ],
307
+ allowCustomValue: false,
308
+ });
309
+ const group = await askGroup(auth, options.refresh);
310
+ const projects = await listProjects(auth, group, !!options.refresh);
311
+ let selectedProjects: TGitLabProject[] = projects;
312
+
313
+ if (mode === "repo") {
314
+ const selectedProjectId = await searchableSelectChoice({
315
+ message: "Search/select GitLab repository",
316
+ choices: projects.map((project) => ({ title: `${project.path_with_namespace} · #${project.id}`, value: String(project.id) })),
317
+ allowCustomValue: false,
318
+ });
319
+ selectedProjects = projects.filter((project) => String(project.id) === selectedProjectId);
320
+ }
321
+
322
+ const dest = await prompts({ type: "text", name: "value", message: "Destination folder", initial: "." });
323
+ const action = await searchableSelectChoice({
324
+ message: "Sync action",
325
+ choices: [
326
+ { title: "Clone missing repos and fetch existing repos", value: "fetch" },
327
+ { title: "Pull current branch only", value: "pull-current" },
328
+ { title: "Pull all remote branches locally", value: "pull-all" },
329
+ ],
330
+ allowCustomValue: false,
331
+ });
332
+ const jobValue = await searchableSelectChoice({
333
+ message: "Parallel jobs",
334
+ choices: ["4", "2", "6", "8"].map((value) => ({ title: `${value} parallel jobs`, value })),
335
+ allowCustomValue: true,
336
+ });
337
+ const concurrency = Math.max(1, Math.min(16, Number(jobValue) || 4));
338
+ const destination = path.resolve(String(dest.value || "."));
339
+ const cache = await readGitLabCache();
340
+ cache.destinations = [destination, ...cache.destinations.filter((item) => item !== destination)].slice(0, 20);
341
+ await writeGitLabCache(cache);
342
+
343
+ console.log(chalk.cyan(`Syncing ${selectedProjects.length} repo(s) with ${concurrency} job(s)...`));
344
+ await parallelRun(selectedProjects, concurrency, async (project, index) => {
345
+ console.log(chalk.blue(`\n[${index + 1}/${selectedProjects.length}] RUN ${project.path_with_namespace}`));
346
+ await cloneOrUpdateProject(project, destination, action, auth);
347
+ console.log(chalk.green(`[${index + 1}/${selectedProjects.length}] DONE ${project.path_with_namespace}`));
348
+ });
349
+ }
350
+
351
+ export function registerGitLabCommands(program: Command): void {
352
+ const gitlab = program.command("gitlab").alias("gl").description("GitLab browser login, group/project scan, clone, sync, and branch fetch helpers");
353
+ gitlab.command("login").description("Login to GitLab and cache auth").action(async () => { await runLoginFlow(); });
354
+ gitlab.command("auth-status").alias("whoami").description("Show cached GitLab auth status").action(async () => {
355
+ const cache = await readGitLabCache();
356
+ if (!cache.instances.length) { console.log("Not logged in."); return; }
357
+ for (const auth of cache.instances) console.log(`${auth.baseUrl} · ${auth.username ?? "user"} · expires ${auth.expiresAt ?? "unknown"}`);
358
+ });
359
+ gitlab.command("logout").description("Remove cached GitLab login").action(async () => { const cache = await readGitLabCache(); cache.instances = []; await writeGitLabCache(cache); console.log("GitLab login cache cleared."); });
360
+ gitlab.command("groups").description("List GitLab root groups").option("--refresh", "Refresh from API").action(async (options: { refresh?: boolean }) => {
361
+ const auth = await askAuth();
362
+ const groups = await listRootGroups(auth, !!options.refresh);
363
+ for (const group of groups) console.log(`${group.full_path} · #${group.id} · ${group.visibility ?? ""}`);
364
+ });
365
+ gitlab.command("projects").description("List projects in a GitLab root group").option("--refresh", "Refresh from API").action(async (options: { refresh?: boolean }) => {
366
+ const auth = await askAuth();
367
+ const group = await askGroup(auth, options.refresh);
368
+ const projects = await listProjects(auth, group, !!options.refresh);
369
+ for (const project of projects) console.log(`${project.path_with_namespace} · #${project.id}`);
370
+ });
371
+ gitlab.command("sync").alias("clone").description("Clone or update GitLab projects without ghorg").option("--refresh", "Refresh groups/projects from API").action(runSync);
372
+ gitlab.command("pull").description("Interactive pull/fetch for GitLab projects").option("--refresh", "Refresh groups/projects from API").action(runSync);
373
+ }