@siamriaz/octomux 1.0.0

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.
@@ -0,0 +1,1957 @@
1
+ // src/index.ts
2
+ import { Command } from "commander";
3
+ import * as p5 from "@clack/prompts";
4
+ import pc8 from "picocolors";
5
+
6
+ // src/commands/account/add.ts
7
+ import ora from "ora";
8
+
9
+ // src/core/account-manager.ts
10
+ import fs4 from "fs";
11
+
12
+ // src/core/config-store.ts
13
+ import fs from "fs";
14
+ import path2 from "path";
15
+
16
+ // src/platform/paths.ts
17
+ import os from "os";
18
+ import path from "path";
19
+ function getHomeDir() {
20
+ return os.homedir();
21
+ }
22
+ function getOctomuxDir() {
23
+ return path.join(getHomeDir(), ".octomux");
24
+ }
25
+ function getConfigFilePath() {
26
+ return path.join(getOctomuxDir(), "config.json");
27
+ }
28
+ function getSshDir() {
29
+ return path.join(getHomeDir(), ".ssh");
30
+ }
31
+ function getSshConfigPath() {
32
+ return path.join(getSshDir(), "config");
33
+ }
34
+ function getDefaultKeyPath(alias, keyType = "ed25519") {
35
+ const filename = `id_${keyType}_octomux_${alias}`;
36
+ return path.join(getSshDir(), filename);
37
+ }
38
+ function toPosixPath(filePath) {
39
+ return filePath.replace(/\\/g, "/");
40
+ }
41
+
42
+ // src/types/config.ts
43
+ import { z as z2 } from "zod";
44
+
45
+ // src/types/account.ts
46
+ import { z } from "zod";
47
+ var SshKeyTypeSchema = z.enum(["ed25519", "rsa"]);
48
+ var SshKeyDetailsSchema = z.object({
49
+ keyPath: z.string().min(1, "Private key path is required"),
50
+ publicKeyPath: z.string().min(1, "Public key path is required"),
51
+ hostAlias: z.string().min(1, "SSH Host alias is required"),
52
+ keyType: SshKeyTypeSchema.default("ed25519")
53
+ });
54
+ var AccountProfileSchema = z.object({
55
+ id: z.string().min(1, "Account alias ID is required").regex(/^[a-z0-9-_]+$/i, "Alias must contain only alphanumeric characters, dashes, or underscores"),
56
+ name: z.string().min(1, "Display name is required"),
57
+ username: z.string().min(1, "GitHub username is required"),
58
+ email: z.string().email("Invalid email address"),
59
+ gitUserName: z.string().min(1, "Git author name is required"),
60
+ ssh: SshKeyDetailsSchema,
61
+ signingKey: z.string().optional(),
62
+ token: z.string().optional(),
63
+ isDefaultGlobal: z.boolean().default(false),
64
+ createdAt: z.string().datetime().default(() => (/* @__PURE__ */ new Date()).toISOString()),
65
+ updatedAt: z.string().datetime().default(() => (/* @__PURE__ */ new Date()).toISOString())
66
+ });
67
+ var CreateAccountInputSchema = z.object({
68
+ id: z.string().min(1, "Alias is required").regex(/^[a-z0-9-_]+$/i, "Alias must contain only letters, numbers, dashes, or underscores"),
69
+ name: z.string().optional(),
70
+ username: z.string().min(1, "GitHub username is required"),
71
+ email: z.string().email("Valid email is required"),
72
+ gitUserName: z.string().optional(),
73
+ sshKeyPath: z.string().optional(),
74
+ generateKey: z.boolean().default(true),
75
+ keyType: SshKeyTypeSchema.default("ed25519"),
76
+ hostAlias: z.string().optional(),
77
+ token: z.string().optional(),
78
+ setAsGlobal: z.boolean().default(false)
79
+ });
80
+ var UpdateAccountInputSchema = z.object({
81
+ name: z.string().optional(),
82
+ username: z.string().optional(),
83
+ email: z.string().email().optional(),
84
+ gitUserName: z.string().optional(),
85
+ sshKeyPath: z.string().optional(),
86
+ hostAlias: z.string().optional(),
87
+ token: z.string().optional()
88
+ });
89
+
90
+ // src/types/config.ts
91
+ var OctomuxConfigSchema = z2.object({
92
+ version: z2.string().default("1.0.0"),
93
+ activeGlobalAccount: z2.string().optional(),
94
+ defaultCloneProtocol: z2.enum(["ssh", "https"]).default("ssh"),
95
+ accounts: z2.record(z2.string(), AccountProfileSchema).default({})
96
+ });
97
+ var DEFAULT_CONFIG = {
98
+ version: "1.0.0",
99
+ defaultCloneProtocol: "ssh",
100
+ accounts: {}
101
+ };
102
+
103
+ // src/core/config-store.ts
104
+ var ConfigStore = class {
105
+ configPath;
106
+ constructor(customConfigPath) {
107
+ this.configPath = customConfigPath ?? getConfigFilePath();
108
+ }
109
+ /**
110
+ * Returns the config file path being managed.
111
+ */
112
+ getPath() {
113
+ return this.configPath;
114
+ }
115
+ /**
116
+ * Loads and validates the octomux configuration.
117
+ * If file does not exist, creates it with DEFAULT_CONFIG.
118
+ */
119
+ load() {
120
+ if (!fs.existsSync(this.configPath)) {
121
+ this.save(DEFAULT_CONFIG);
122
+ return { ...DEFAULT_CONFIG };
123
+ }
124
+ try {
125
+ const raw = fs.readFileSync(this.configPath, "utf-8");
126
+ const parsed = JSON.parse(raw);
127
+ const validated = OctomuxConfigSchema.parse(parsed);
128
+ return validated;
129
+ } catch (error) {
130
+ if (error instanceof SyntaxError) {
131
+ throw new Error(`Corrupted config file at ${this.configPath}: Invalid JSON format.`);
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+ /**
137
+ * Atomically saves the configuration with backup creation.
138
+ */
139
+ save(config) {
140
+ const validated = OctomuxConfigSchema.parse(config);
141
+ const dir = path2.dirname(this.configPath);
142
+ if (!fs.existsSync(dir)) {
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ }
145
+ if (fs.existsSync(this.configPath)) {
146
+ const backupPath = `${this.configPath}.bak`;
147
+ try {
148
+ fs.copyFileSync(this.configPath, backupPath);
149
+ } catch {
150
+ }
151
+ }
152
+ const tempPath = `${this.configPath}.${Date.now()}.tmp`;
153
+ const serialized = JSON.stringify(validated, null, 2);
154
+ fs.writeFileSync(tempPath, serialized, "utf-8");
155
+ fs.renameSync(tempPath, this.configPath);
156
+ }
157
+ /**
158
+ * Retrieves all configured account profiles.
159
+ */
160
+ getAccounts() {
161
+ const config = this.load();
162
+ return config.accounts;
163
+ }
164
+ /**
165
+ * Retrieves a single account profile by its alias.
166
+ */
167
+ getAccount(alias) {
168
+ const accounts = this.getAccounts();
169
+ return accounts[alias];
170
+ }
171
+ /**
172
+ * Saves or updates an account profile.
173
+ */
174
+ setAccount(account) {
175
+ const config = this.load();
176
+ config.accounts[account.id] = account;
177
+ if (account.isDefaultGlobal) {
178
+ config.activeGlobalAccount = account.id;
179
+ for (const [id, acc] of Object.entries(config.accounts)) {
180
+ if (id !== account.id) {
181
+ acc.isDefaultGlobal = false;
182
+ }
183
+ }
184
+ }
185
+ this.save(config);
186
+ }
187
+ /**
188
+ * Removes an account profile by alias.
189
+ */
190
+ removeAccount(alias) {
191
+ const config = this.load();
192
+ if (!config.accounts[alias]) {
193
+ return false;
194
+ }
195
+ delete config.accounts[alias];
196
+ if (config.activeGlobalAccount === alias) {
197
+ delete config.activeGlobalAccount;
198
+ }
199
+ this.save(config);
200
+ return true;
201
+ }
202
+ /**
203
+ * Sets the active global account alias.
204
+ */
205
+ setActiveGlobal(alias) {
206
+ const config = this.load();
207
+ if (!config.accounts[alias]) {
208
+ throw new Error(`Cannot set active global account: Profile '${alias}' does not exist.`);
209
+ }
210
+ config.activeGlobalAccount = alias;
211
+ for (const [id, acc] of Object.entries(config.accounts)) {
212
+ acc.isDefaultGlobal = id === alias;
213
+ }
214
+ this.save(config);
215
+ }
216
+ /**
217
+ * Gets the active global account profile if one is set.
218
+ */
219
+ getActiveGlobal() {
220
+ const config = this.load();
221
+ if (!config.activeGlobalAccount) {
222
+ return void 0;
223
+ }
224
+ return config.accounts[config.activeGlobalAccount];
225
+ }
226
+ };
227
+
228
+ // src/core/ssh-service.ts
229
+ import fs3 from "fs";
230
+ import path3 from "path";
231
+ import { execa } from "execa";
232
+
233
+ // src/platform/permissions.ts
234
+ import fs2 from "fs";
235
+ import os2 from "os";
236
+ function setPrivateKeyPermissions(filePath) {
237
+ if (os2.platform() === "win32") {
238
+ return;
239
+ }
240
+ try {
241
+ if (fs2.existsSync(filePath)) {
242
+ fs2.chmodSync(filePath, 384);
243
+ }
244
+ } catch {
245
+ }
246
+ }
247
+ function setSshDirPermissions(dirPath) {
248
+ if (os2.platform() === "win32") {
249
+ return;
250
+ }
251
+ try {
252
+ if (fs2.existsSync(dirPath)) {
253
+ fs2.chmodSync(dirPath, 448);
254
+ }
255
+ } catch {
256
+ }
257
+ }
258
+
259
+ // src/core/ssh-service.ts
260
+ var OCTOMUX_BLOCK_START = "# === OCTOMUX MANAGED HOSTS: START ===";
261
+ var OCTOMUX_BLOCK_END = "# === OCTOMUX MANAGED HOSTS: END ===";
262
+ var SshService = class {
263
+ sshConfigPath;
264
+ sshDir;
265
+ constructor(customSshConfigPath, customSshDir) {
266
+ this.sshConfigPath = customSshConfigPath ?? getSshConfigPath();
267
+ this.sshDir = customSshDir ?? getSshDir();
268
+ }
269
+ getSshConfigPath() {
270
+ return this.sshConfigPath;
271
+ }
272
+ getSshDir() {
273
+ return this.sshDir;
274
+ }
275
+ /**
276
+ * Scans ~/.ssh directory for existing private SSH keys created by the user or system.
277
+ */
278
+ scanExistingSshKeys() {
279
+ if (!fs3.existsSync(this.sshDir)) {
280
+ return [];
281
+ }
282
+ const ignoredFiles = /* @__PURE__ */ new Set([
283
+ "config",
284
+ "config.bak",
285
+ "known_hosts",
286
+ "known_hosts.old",
287
+ "authorized_keys"
288
+ ]);
289
+ const discovered = [];
290
+ const entries = fs3.readdirSync(this.sshDir);
291
+ for (const filename of entries) {
292
+ if (filename.endsWith(".pub") || filename.endsWith(".tmp") || filename.endsWith(".bak")) {
293
+ continue;
294
+ }
295
+ if (ignoredFiles.has(filename.toLowerCase())) {
296
+ continue;
297
+ }
298
+ const privatePath = path3.join(this.sshDir, filename);
299
+ const stat = fs3.statSync(privatePath);
300
+ if (!stat.isFile()) {
301
+ continue;
302
+ }
303
+ const pubPath = `${privatePath}.pub`;
304
+ const hasPub = fs3.existsSync(pubPath);
305
+ let keyType = "unknown";
306
+ let comment;
307
+ if (hasPub) {
308
+ try {
309
+ const pubContent = fs3.readFileSync(pubPath, "utf-8").trim();
310
+ const parts = pubContent.split(/\s+/);
311
+ if (parts.length >= 1 && parts[0]) {
312
+ if (parts[0].includes("ed25519")) keyType = "ed25519";
313
+ else if (parts[0].includes("rsa")) keyType = "rsa";
314
+ else if (parts[0].includes("ecdsa")) keyType = "ecdsa";
315
+ }
316
+ if (parts.length >= 3) {
317
+ comment = parts.slice(2).join(" ");
318
+ }
319
+ } catch {
320
+ }
321
+ } else {
322
+ if (filename.includes("ed25519")) keyType = "ed25519";
323
+ else if (filename.includes("rsa")) keyType = "rsa";
324
+ else if (filename.includes("ecdsa")) keyType = "ecdsa";
325
+ }
326
+ discovered.push({
327
+ name: filename,
328
+ privateKeyPath: privatePath,
329
+ publicKeyPath: hasPub ? pubPath : void 0,
330
+ keyType,
331
+ comment,
332
+ isOctomuxManaged: filename.includes("_octomux_")
333
+ });
334
+ }
335
+ return discovered;
336
+ }
337
+ /**
338
+ * Generates a new SSH key pair using ssh-keygen.
339
+ */
340
+ async generateKeyPair(email, targetKeyPath, keyType = "ed25519", comment) {
341
+ const parentDir = path3.dirname(targetKeyPath);
342
+ if (!fs3.existsSync(parentDir)) {
343
+ fs3.mkdirSync(parentDir, { recursive: true });
344
+ setSshDirPermissions(parentDir);
345
+ }
346
+ if (fs3.existsSync(targetKeyPath)) {
347
+ throw new Error(`SSH Key already exists at: ${targetKeyPath}`);
348
+ }
349
+ const keyComment = comment || `octomux-${email}`;
350
+ const args = ["-t", keyType, "-C", keyComment, "-f", targetKeyPath, "-N", ""];
351
+ if (keyType === "rsa") {
352
+ args.push("-b", "4096");
353
+ }
354
+ try {
355
+ await execa("ssh-keygen", args);
356
+ } catch (err) {
357
+ const errorMsg = err instanceof Error ? err.message : String(err);
358
+ throw new Error(`Failed to generate SSH key with ssh-keygen: ${errorMsg}`);
359
+ }
360
+ setPrivateKeyPermissions(targetKeyPath);
361
+ const publicKeyPath = `${targetKeyPath}.pub`;
362
+ let publicKeyContent = "";
363
+ if (fs3.existsSync(publicKeyPath)) {
364
+ publicKeyContent = fs3.readFileSync(publicKeyPath, "utf-8").trim();
365
+ }
366
+ return {
367
+ privateKeyPath: targetKeyPath,
368
+ publicKeyPath,
369
+ publicKeyContent
370
+ };
371
+ }
372
+ /**
373
+ * Reads an existing public key associated with a private key.
374
+ */
375
+ getPublicKey(privateKeyPath) {
376
+ const pubPath = `${privateKeyPath}.pub`;
377
+ if (fs3.existsSync(pubPath)) {
378
+ return fs3.readFileSync(pubPath, "utf-8").trim();
379
+ }
380
+ return void 0;
381
+ }
382
+ /**
383
+ * Generates formatted SSH config block content for an array of host entries.
384
+ */
385
+ formatHostEntries(hosts) {
386
+ const lines = [
387
+ OCTOMUX_BLOCK_START,
388
+ "# This block is automatically managed by octomux (omx).",
389
+ "# Manual changes inside this block will be overwritten.",
390
+ ""
391
+ ];
392
+ for (const host of hosts) {
393
+ const posixKeyPath = toPosixPath(host.identityFile);
394
+ lines.push(`Host ${host.host}`);
395
+ lines.push(` HostName ${host.hostName}`);
396
+ lines.push(` User ${host.user}`);
397
+ lines.push(` IdentityFile "${posixKeyPath}"`);
398
+ lines.push(` IdentitiesOnly ${host.identitiesOnly !== false ? "yes" : "no"}`);
399
+ if (host.extraOptions) {
400
+ for (const [key, val] of Object.entries(host.extraOptions)) {
401
+ lines.push(` ${key} ${val}`);
402
+ }
403
+ }
404
+ lines.push("");
405
+ }
406
+ lines.push(OCTOMUX_BLOCK_END);
407
+ return lines.join("\n");
408
+ }
409
+ /**
410
+ * Safely synchronizes octomux host entries into ~/.ssh/config.
411
+ * Preserves all user configurations outside of the octomux delimiters.
412
+ */
413
+ syncSshConfig(hosts) {
414
+ const dir = path3.dirname(this.sshConfigPath);
415
+ if (!fs3.existsSync(dir)) {
416
+ fs3.mkdirSync(dir, { recursive: true });
417
+ setSshDirPermissions(dir);
418
+ }
419
+ let existingContent = "";
420
+ if (fs3.existsSync(this.sshConfigPath)) {
421
+ existingContent = fs3.readFileSync(this.sshConfigPath, "utf-8");
422
+ try {
423
+ fs3.copyFileSync(this.sshConfigPath, `${this.sshConfigPath}.bak`);
424
+ } catch {
425
+ }
426
+ }
427
+ const newBlock = this.formatHostEntries(hosts);
428
+ let updatedContent = "";
429
+ const startIndex = existingContent.indexOf(OCTOMUX_BLOCK_START);
430
+ const endIndex = existingContent.indexOf(OCTOMUX_BLOCK_END);
431
+ if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
432
+ const before = existingContent.slice(0, startIndex).trimEnd();
433
+ const after = existingContent.slice(endIndex + OCTOMUX_BLOCK_END.length).trimStart();
434
+ const parts = [];
435
+ if (before.length > 0) parts.push(before);
436
+ parts.push(newBlock);
437
+ if (after.length > 0) parts.push(after);
438
+ updatedContent = parts.join("\n\n") + "\n";
439
+ } else {
440
+ const trimmed = existingContent.trim();
441
+ updatedContent = trimmed ? `${trimmed}
442
+
443
+ ${newBlock}
444
+ ` : `${newBlock}
445
+ `;
446
+ }
447
+ const tempFile = `${this.sshConfigPath}.${Date.now()}.tmp`;
448
+ fs3.writeFileSync(tempFile, updatedContent, "utf-8");
449
+ setPrivateKeyPermissions(tempFile);
450
+ fs3.renameSync(tempFile, this.sshConfigPath);
451
+ setPrivateKeyPermissions(this.sshConfigPath);
452
+ }
453
+ /**
454
+ * Tests SSH connection to a GitHub host alias.
455
+ * Note: GitHub returns exit code 1 with success greeting on SSH -T.
456
+ */
457
+ async testConnection(hostAlias, username = "User") {
458
+ try {
459
+ const { stdout, stderr } = await execa(
460
+ "ssh",
461
+ [
462
+ "-T",
463
+ "-o",
464
+ "BatchMode=yes",
465
+ "-o",
466
+ "StrictHostKeyChecking=accept-new",
467
+ "-o",
468
+ "ConnectTimeout=8",
469
+ `git@${hostAlias}`
470
+ ],
471
+ { reject: false }
472
+ );
473
+ const output = `${stdout}
474
+ ${stderr}`.trim();
475
+ const isAuthenticated = output.includes("successfully authenticated") || output.toLowerCase().includes(`hi ${username.toLowerCase()}`);
476
+ return {
477
+ accountAlias: hostAlias.replace(/^github\.com-/, ""),
478
+ hostAlias,
479
+ username,
480
+ authenticated: isAuthenticated,
481
+ rawOutput: output,
482
+ error: isAuthenticated ? void 0 : output
483
+ };
484
+ } catch (err) {
485
+ const errorMsg = err instanceof Error ? err.message : String(err);
486
+ return {
487
+ accountAlias: hostAlias.replace(/^github\.com-/, ""),
488
+ hostAlias,
489
+ username,
490
+ authenticated: false,
491
+ rawOutput: "",
492
+ error: errorMsg
493
+ };
494
+ }
495
+ }
496
+ };
497
+
498
+ // src/core/git-service.ts
499
+ import { execa as execa2 } from "execa";
500
+ var GitService = class {
501
+ /**
502
+ * Checks if git is installed and available in PATH.
503
+ */
504
+ async isGitInstalled() {
505
+ try {
506
+ const { exitCode } = await execa2("git", ["--version"]);
507
+ return exitCode === 0;
508
+ } catch {
509
+ return false;
510
+ }
511
+ }
512
+ /**
513
+ * Checks if the given directory (or current working directory) is inside a Git repository.
514
+ */
515
+ async isInsideGitRepo(cwd) {
516
+ try {
517
+ const { stdout } = await execa2("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
518
+ return stdout.trim() === "true";
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+ /**
524
+ * Gets the root directory of the current Git repository.
525
+ */
526
+ async getRepoRoot(cwd) {
527
+ try {
528
+ const { stdout } = await execa2("git", ["rev-parse", "--show-toplevel"], { cwd });
529
+ return stdout.trim();
530
+ } catch {
531
+ return void 0;
532
+ }
533
+ }
534
+ /**
535
+ * Reads Git identity (user.name, user.email, core.sshCommand) for a given scope.
536
+ */
537
+ async getIdentity(scope, cwd) {
538
+ const flag = scope === "global" ? "--global" : "--local";
539
+ const identity = {};
540
+ try {
541
+ const { stdout: name } = await execa2("git", ["config", flag, "--get", "user.name"], { cwd, reject: false });
542
+ if (name.trim()) identity.name = name.trim();
543
+ } catch {
544
+ }
545
+ try {
546
+ const { stdout: email } = await execa2("git", ["config", flag, "--get", "user.email"], { cwd, reject: false });
547
+ if (email.trim()) identity.email = email.trim();
548
+ } catch {
549
+ }
550
+ try {
551
+ const { stdout: sshCmd } = await execa2("git", ["config", flag, "--get", "core.sshCommand"], { cwd, reject: false });
552
+ if (sshCmd.trim()) identity.sshCommand = sshCmd.trim();
553
+ } catch {
554
+ }
555
+ return identity;
556
+ }
557
+ /**
558
+ * Sets global Git identity.
559
+ */
560
+ async setGlobalIdentity(name, email) {
561
+ await execa2("git", ["config", "--global", "user.name", name]);
562
+ await execa2("git", ["config", "--global", "user.email", email]);
563
+ }
564
+ /**
565
+ * Sets local Git identity and dedicated SSH command in a repository.
566
+ */
567
+ async setLocalIdentity(name, email, sshKeyPath, cwd) {
568
+ const isRepo = await this.isInsideGitRepo(cwd);
569
+ if (!isRepo) {
570
+ throw new Error(`Current directory is not inside a Git repository.`);
571
+ }
572
+ await execa2("git", ["config", "--local", "user.name", name], { cwd });
573
+ await execa2("git", ["config", "--local", "user.email", email], { cwd });
574
+ if (sshKeyPath) {
575
+ const posixKey = toPosixPath(sshKeyPath);
576
+ const sshCommand = `ssh -i "${posixKey}" -o IdentitiesOnly=yes`;
577
+ await execa2("git", ["config", "--local", "core.sshCommand", sshCommand], { cwd });
578
+ }
579
+ }
580
+ /**
581
+ * Parses GitHub URLs, SSH strings, or owner/repo shorthand slugs.
582
+ */
583
+ parseRepoInput(input) {
584
+ const trimmed = input.trim();
585
+ const slugMatch = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/.exec(trimmed);
586
+ if (slugMatch && slugMatch[1] && slugMatch[2]) {
587
+ const repo = slugMatch[2].replace(/\.git$/, "");
588
+ return { owner: slugMatch[1], repo, originalUrl: trimmed };
589
+ }
590
+ const httpsMatch = /^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(\.git)?(?:\/)?$/.exec(trimmed);
591
+ if (httpsMatch && httpsMatch[1] && httpsMatch[2]) {
592
+ return { owner: httpsMatch[1], repo: httpsMatch[2], originalUrl: trimmed };
593
+ }
594
+ const sshMatch = /^git@([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(\.git)?$/.exec(trimmed);
595
+ if (sshMatch && sshMatch[2] && sshMatch[3]) {
596
+ return { owner: sshMatch[2], repo: sshMatch[3], originalUrl: trimmed };
597
+ }
598
+ return null;
599
+ }
600
+ /**
601
+ * Constructs an SSH clone URL using a specific SSH host alias.
602
+ */
603
+ formatSshCloneUrl(hostAlias, owner, repo) {
604
+ return `git@${hostAlias}:${owner}/${repo}.git`;
605
+ }
606
+ /**
607
+ * Executes git clone with real-time output streaming or promise resolution.
608
+ */
609
+ async clone(cloneUrl, targetDir, extraArgs = []) {
610
+ const args = ["clone", cloneUrl];
611
+ if (targetDir) {
612
+ args.push(targetDir);
613
+ }
614
+ args.push(...extraArgs);
615
+ await execa2("git", args, { stdio: "inherit" });
616
+ if (targetDir) {
617
+ return targetDir;
618
+ }
619
+ const match = /\/([^/]+?)(\.git)?$/.exec(cloneUrl);
620
+ return match && match[1] ? match[1] : "repository";
621
+ }
622
+ /**
623
+ * Gets the current remote URL for a repository (origin by default).
624
+ */
625
+ async getRemoteUrl(remote = "origin", cwd) {
626
+ try {
627
+ const { stdout } = await execa2("git", ["remote", "get-url", remote], { cwd });
628
+ return stdout.trim();
629
+ } catch {
630
+ return void 0;
631
+ }
632
+ }
633
+ /**
634
+ * Sets the remote URL for a repository (or adds it if not existing).
635
+ */
636
+ async setRemoteUrl(remote, url, cwd) {
637
+ await execa2("git", ["remote", "set-url", remote, url], { cwd });
638
+ }
639
+ /**
640
+ * Adds a new remote to the repository.
641
+ */
642
+ async addRemoteUrl(remote, url, cwd) {
643
+ await execa2("git", ["remote", "add", remote, url], { cwd });
644
+ }
645
+ /**
646
+ * Initializes a new Git repository if not already initialized.
647
+ */
648
+ async initRepo(cwd) {
649
+ await execa2("git", ["init"], { cwd });
650
+ }
651
+ };
652
+
653
+ // src/core/account-manager.ts
654
+ var AccountManager = class {
655
+ configStore;
656
+ sshService;
657
+ gitService;
658
+ constructor(configStore, sshService, gitService) {
659
+ this.configStore = configStore ?? new ConfigStore();
660
+ this.sshService = sshService ?? new SshService();
661
+ this.gitService = gitService ?? new GitService();
662
+ }
663
+ /**
664
+ * Adds a new GitHub account profile, sets up SSH key and configures SSH hosts.
665
+ */
666
+ async addAccount(input) {
667
+ const validatedInput = CreateAccountInputSchema.parse(input);
668
+ const existing = this.configStore.getAccount(validatedInput.id);
669
+ if (existing) {
670
+ throw new Error(`An account with alias '${validatedInput.id}' already exists.`);
671
+ }
672
+ const hostAlias = validatedInput.hostAlias || `github.com-${validatedInput.id}`;
673
+ let privateKeyPath = validatedInput.sshKeyPath;
674
+ let publicKeyPath = "";
675
+ if (validatedInput.generateKey || !privateKeyPath) {
676
+ const defaultKeyPath = getDefaultKeyPath(validatedInput.id, validatedInput.keyType);
677
+ const keyGenResult = await this.sshService.generateKeyPair(
678
+ validatedInput.email,
679
+ defaultKeyPath,
680
+ validatedInput.keyType,
681
+ `octomux-${validatedInput.id}`
682
+ );
683
+ privateKeyPath = keyGenResult.privateKeyPath;
684
+ publicKeyPath = keyGenResult.publicKeyPath;
685
+ } else {
686
+ if (!fs4.existsSync(privateKeyPath)) {
687
+ throw new Error(`Specified private SSH key does not exist at: ${privateKeyPath}`);
688
+ }
689
+ publicKeyPath = `${privateKeyPath}.pub`;
690
+ if (!fs4.existsSync(publicKeyPath)) {
691
+ publicKeyPath = privateKeyPath;
692
+ }
693
+ }
694
+ const profile = AccountProfileSchema.parse({
695
+ id: validatedInput.id,
696
+ name: validatedInput.name || validatedInput.username,
697
+ username: validatedInput.username,
698
+ email: validatedInput.email,
699
+ gitUserName: validatedInput.gitUserName || validatedInput.username,
700
+ ssh: {
701
+ keyPath: privateKeyPath,
702
+ publicKeyPath,
703
+ hostAlias,
704
+ keyType: validatedInput.keyType
705
+ },
706
+ token: validatedInput.token,
707
+ isDefaultGlobal: validatedInput.setAsGlobal,
708
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
709
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
710
+ });
711
+ this.configStore.setAccount(profile);
712
+ this.syncAllSshHosts();
713
+ if (validatedInput.setAsGlobal) {
714
+ await this.gitService.setGlobalIdentity(profile.gitUserName, profile.email);
715
+ }
716
+ return profile;
717
+ }
718
+ /**
719
+ * Updates an existing account profile.
720
+ */
721
+ async updateAccount(alias, input) {
722
+ const validatedInput = UpdateAccountInputSchema.parse(input);
723
+ const existing = this.configStore.getAccount(alias);
724
+ if (!existing) {
725
+ throw new Error(`Account profile '${alias}' does not exist.`);
726
+ }
727
+ let privateKeyPath = existing.ssh.keyPath;
728
+ let publicKeyPath = existing.ssh.publicKeyPath;
729
+ if (validatedInput.sshKeyPath && validatedInput.sshKeyPath !== existing.ssh.keyPath) {
730
+ if (!fs4.existsSync(validatedInput.sshKeyPath)) {
731
+ throw new Error(`Specified SSH key does not exist at: ${validatedInput.sshKeyPath}`);
732
+ }
733
+ privateKeyPath = validatedInput.sshKeyPath;
734
+ publicKeyPath = fs4.existsSync(`${privateKeyPath}.pub`) ? `${privateKeyPath}.pub` : privateKeyPath;
735
+ }
736
+ const updated = AccountProfileSchema.parse({
737
+ ...existing,
738
+ name: validatedInput.name ?? existing.name,
739
+ username: validatedInput.username ?? existing.username,
740
+ email: validatedInput.email ?? existing.email,
741
+ gitUserName: validatedInput.gitUserName ?? existing.gitUserName,
742
+ token: validatedInput.token !== void 0 ? validatedInput.token : existing.token,
743
+ ssh: {
744
+ ...existing.ssh,
745
+ keyPath: privateKeyPath,
746
+ publicKeyPath,
747
+ hostAlias: validatedInput.hostAlias ?? existing.ssh.hostAlias
748
+ },
749
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
750
+ });
751
+ this.configStore.setAccount(updated);
752
+ this.syncAllSshHosts();
753
+ return updated;
754
+ }
755
+ /**
756
+ * Removes an account profile and optionally deletes its SSH keys.
757
+ */
758
+ removeAccount(alias, deleteSshKey = false) {
759
+ const existing = this.configStore.getAccount(alias);
760
+ if (!existing) {
761
+ return false;
762
+ }
763
+ if (deleteSshKey) {
764
+ try {
765
+ if (fs4.existsSync(existing.ssh.keyPath)) {
766
+ fs4.unlinkSync(existing.ssh.keyPath);
767
+ }
768
+ if (fs4.existsSync(existing.ssh.publicKeyPath)) {
769
+ fs4.unlinkSync(existing.ssh.publicKeyPath);
770
+ }
771
+ } catch {
772
+ }
773
+ }
774
+ const removed = this.configStore.removeAccount(alias);
775
+ this.syncAllSshHosts();
776
+ return removed;
777
+ }
778
+ /**
779
+ * Returns a list of all configured account profiles.
780
+ */
781
+ listAccounts() {
782
+ const accounts = this.configStore.getAccounts();
783
+ return Object.values(accounts).sort((a, b) => a.id.localeCompare(b.id));
784
+ }
785
+ /**
786
+ * Gets an account profile by alias.
787
+ */
788
+ getAccount(alias) {
789
+ return this.configStore.getAccount(alias);
790
+ }
791
+ /**
792
+ * Switches the global Git user to the specified account.
793
+ */
794
+ async switchGlobal(alias) {
795
+ const account = this.configStore.getAccount(alias);
796
+ if (!account) {
797
+ throw new Error(`Account profile '${alias}' not found.`);
798
+ }
799
+ await this.gitService.setGlobalIdentity(account.gitUserName, account.email);
800
+ this.configStore.setActiveGlobal(alias);
801
+ return account;
802
+ }
803
+ /**
804
+ * Switches the local Git user in the current repository to the specified account.
805
+ */
806
+ async switchLocal(alias, cwd) {
807
+ const account = this.configStore.getAccount(alias);
808
+ if (!account) {
809
+ throw new Error(`Account profile '${alias}' not found.`);
810
+ }
811
+ await this.gitService.setLocalIdentity(
812
+ account.gitUserName,
813
+ account.email,
814
+ account.ssh.keyPath,
815
+ cwd
816
+ );
817
+ return account;
818
+ }
819
+ /**
820
+ * Re-syncs all accounts into ~/.ssh/config.
821
+ */
822
+ syncAllSshHosts() {
823
+ const accounts = this.listAccounts();
824
+ const hostConfigs = accounts.map((acc) => ({
825
+ host: acc.ssh.hostAlias,
826
+ hostName: "github.com",
827
+ user: "git",
828
+ identityFile: acc.ssh.keyPath,
829
+ identitiesOnly: true
830
+ }));
831
+ this.sshService.syncSshConfig(hostConfigs);
832
+ }
833
+ /**
834
+ * Tests SSH connectivity for a specific account.
835
+ */
836
+ async testAccount(alias) {
837
+ const account = this.configStore.getAccount(alias);
838
+ if (!account) {
839
+ throw new Error(`Account profile '${alias}' not found.`);
840
+ }
841
+ return this.sshService.testConnection(account.ssh.hostAlias, account.username);
842
+ }
843
+ };
844
+
845
+ // src/ui/prompts.ts
846
+ import * as p from "@clack/prompts";
847
+ import pc from "picocolors";
848
+ function handleCancel(value) {
849
+ if (p.isCancel(value)) {
850
+ p.cancel("Operation cancelled.");
851
+ process.exit(0);
852
+ }
853
+ }
854
+ async function promptAddAccount() {
855
+ p.intro(pc.bold(pc.cyan("octomux (omx) \u2014 Add New GitHub Account")));
856
+ const id = await p.text({
857
+ message: "Enter an alias ID for this account (e.g. work, personal, opensource):",
858
+ placeholder: "work",
859
+ validate: (val) => {
860
+ if (!val || !val.trim()) return "Alias ID is required";
861
+ if (!/^[a-z0-9-_]+$/i.test(val)) return "Alias must contain only alphanumeric characters, dashes, or underscores";
862
+ return void 0;
863
+ }
864
+ });
865
+ handleCancel(id);
866
+ const username = await p.text({
867
+ message: "Enter GitHub username (e.g. octocat):",
868
+ placeholder: "octocat",
869
+ validate: (val) => !val || !val.trim() ? "GitHub username is required" : void 0
870
+ });
871
+ handleCancel(username);
872
+ const email = await p.text({
873
+ message: "Enter Git commit email (e.g. user@example.com):",
874
+ placeholder: "user@example.com",
875
+ validate: (val) => {
876
+ if (!val || !val.trim()) return "Email is required";
877
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return "Invalid email address";
878
+ return void 0;
879
+ }
880
+ });
881
+ handleCancel(email);
882
+ const gitUserName = await p.text({
883
+ message: "Enter Git author name (used in commit history):",
884
+ placeholder: username,
885
+ initialValue: username
886
+ });
887
+ handleCancel(gitUserName);
888
+ const sshService = new SshService();
889
+ const existingKeys = sshService.scanExistingSshKeys();
890
+ const keyOptions = [];
891
+ for (const k of existingKeys) {
892
+ const commentStr = k.comment ? ` (${k.comment})` : "";
893
+ keyOptions.push({
894
+ value: `existing:${k.privateKeyPath}`,
895
+ label: `\u{1F511} Use detected key: ${k.name} [${k.keyType}]${commentStr}`
896
+ });
897
+ }
898
+ keyOptions.push(
899
+ { value: "generate_ed25519", label: "\u2795 Generate new Ed25519 key (Recommended)" },
900
+ { value: "generate_rsa", label: "\u2795 Generate new RSA 4096-bit key" },
901
+ { value: "manual", label: "\u{1F4C1} Specify a custom private key path..." }
902
+ );
903
+ const keyChoice = await p.select({
904
+ message: "SSH Key configuration:",
905
+ options: keyOptions
906
+ });
907
+ handleCancel(keyChoice);
908
+ let generateKey = true;
909
+ let keyType = "ed25519";
910
+ let sshKeyPath;
911
+ const keyChoiceStr = keyChoice;
912
+ if (keyChoiceStr.startsWith("existing:")) {
913
+ generateKey = false;
914
+ sshKeyPath = keyChoiceStr.replace("existing:", "");
915
+ keyType = sshKeyPath.includes("rsa") ? "rsa" : "ed25519";
916
+ } else if (keyChoiceStr === "generate_ed25519") {
917
+ generateKey = true;
918
+ keyType = "ed25519";
919
+ } else if (keyChoiceStr === "generate_rsa") {
920
+ generateKey = true;
921
+ keyType = "rsa";
922
+ } else {
923
+ generateKey = false;
924
+ const pathInput = await p.text({
925
+ message: "Enter path to existing private SSH key:",
926
+ placeholder: "~/.ssh/id_rsa",
927
+ validate: (val) => !val || !val.trim() ? "Private key path is required" : void 0
928
+ });
929
+ handleCancel(pathInput);
930
+ sshKeyPath = pathInput;
931
+ }
932
+ const setAsGlobal = await p.confirm({
933
+ message: "Set this account as the active global Git identity now?",
934
+ initialValue: false
935
+ });
936
+ handleCancel(setAsGlobal);
937
+ return {
938
+ id: id.trim(),
939
+ name: username.trim(),
940
+ username: username.trim(),
941
+ email: email.trim(),
942
+ gitUserName: (gitUserName || username).trim(),
943
+ generateKey,
944
+ keyType,
945
+ sshKeyPath,
946
+ setAsGlobal: Boolean(setAsGlobal)
947
+ };
948
+ }
949
+ async function promptSelectAccount(accounts, message = "Select an account profile:") {
950
+ if (accounts.length === 0) {
951
+ throw new Error('No accounts available. Please add an account first with "omx account add".');
952
+ }
953
+ const selected = await p.select({
954
+ message,
955
+ options: accounts.map((acc) => ({
956
+ value: acc.id,
957
+ label: `${pc.bold(acc.id)} (@${acc.username} - ${acc.email})`
958
+ }))
959
+ });
960
+ handleCancel(selected);
961
+ const found = accounts.find((a) => a.id === selected);
962
+ if (!found) {
963
+ throw new Error("Selected account not found.");
964
+ }
965
+ return found;
966
+ }
967
+ async function promptEditAccount(existing) {
968
+ p.intro(pc.bold(pc.cyan(`Edit Account: ${existing.id}`)));
969
+ const username = await p.text({
970
+ message: "GitHub username:",
971
+ initialValue: existing.username
972
+ });
973
+ handleCancel(username);
974
+ const email = await p.text({
975
+ message: "Git commit email:",
976
+ initialValue: existing.email
977
+ });
978
+ handleCancel(email);
979
+ const gitUserName = await p.text({
980
+ message: "Git author name:",
981
+ initialValue: existing.gitUserName
982
+ });
983
+ handleCancel(gitUserName);
984
+ return {
985
+ username,
986
+ email,
987
+ gitUserName
988
+ };
989
+ }
990
+ async function promptConfirm(message, initialValue = false) {
991
+ const result = await p.confirm({
992
+ message,
993
+ initialValue
994
+ });
995
+ handleCancel(result);
996
+ return Boolean(result);
997
+ }
998
+
999
+ // src/ui/logger.ts
1000
+ import pc2 from "picocolors";
1001
+ import boxen from "boxen";
1002
+ var logger = {
1003
+ success(message) {
1004
+ console.log(`${pc2.green("\u2714")} ${message}`);
1005
+ },
1006
+ error(message) {
1007
+ console.error(`${pc2.red("\u2716")} ${pc2.red(message)}`);
1008
+ },
1009
+ warn(message) {
1010
+ console.warn(`${pc2.yellow("\u26A0")} ${message}`);
1011
+ },
1012
+ info(message) {
1013
+ console.log(`${pc2.cyan("\u2139")} ${message}`);
1014
+ },
1015
+ step(stepNumber, total, message) {
1016
+ console.log(`${pc2.magenta(`[${stepNumber}/${total}]`)} ${message}`);
1017
+ },
1018
+ dim(message) {
1019
+ console.log(pc2.dim(message));
1020
+ },
1021
+ highlight(label, value) {
1022
+ console.log(` ${pc2.bold(label)}: ${pc2.cyan(value)}`);
1023
+ },
1024
+ box(content, title, borderColor = "cyan") {
1025
+ console.log(
1026
+ boxen(content, {
1027
+ padding: 1,
1028
+ margin: { top: 0, bottom: 0 },
1029
+ borderStyle: "round",
1030
+ borderColor,
1031
+ title: title ? pc2.bold(title) : void 0,
1032
+ titleAlignment: "left"
1033
+ })
1034
+ );
1035
+ }
1036
+ };
1037
+
1038
+ // src/ui/formatters.ts
1039
+ import pc3 from "picocolors";
1040
+ function formatPublicKeyGuide(account, publicKeyContent) {
1041
+ const lines = [
1042
+ pc3.bold(pc3.green("Account configured successfully!")),
1043
+ "",
1044
+ `${pc3.bold("Next Step:")} Add your SSH Public Key to your GitHub Account:`,
1045
+ pc3.cyan("https://github.com/settings/ssh/new"),
1046
+ "",
1047
+ `${pc3.bold("Public Key Path:")} ${pc3.dim(account.ssh.publicKeyPath)}`
1048
+ ];
1049
+ if (publicKeyContent) {
1050
+ lines.push("");
1051
+ lines.push(pc3.bold("Public Key Content:"));
1052
+ lines.push(pc3.yellow(publicKeyContent));
1053
+ }
1054
+ return lines.join("\n");
1055
+ }
1056
+ function formatTestResult(result) {
1057
+ if (result.authenticated) {
1058
+ return [
1059
+ pc3.green(`\u2714 SSH connection to ${pc3.bold(result.hostAlias)} succeeded!`),
1060
+ pc3.dim(` GitHub greeting: Hi ${result.username}! You've successfully authenticated.`)
1061
+ ].join("\n");
1062
+ }
1063
+ return [
1064
+ pc3.red(`\u2716 SSH connection to ${pc3.bold(result.hostAlias)} failed.`),
1065
+ pc3.yellow(` Error output: ${result.error || "Authentication rejected."}`),
1066
+ pc3.dim(` Tip: Make sure you added your public key at https://github.com/settings/keys`)
1067
+ ].join("\n");
1068
+ }
1069
+ function formatCloneSummary(repoName, targetDir, account) {
1070
+ return [
1071
+ pc3.bold(pc3.green(`Repository cloned and configured successfully!`)),
1072
+ "",
1073
+ ` ${pc3.bold("Repository:")} ${pc3.cyan(repoName)}`,
1074
+ ` ${pc3.bold("Location:")} ${targetDir}`,
1075
+ ` ${pc3.bold("Account:")} ${pc3.yellow(account.id)} (@${account.username})`,
1076
+ ` ${pc3.bold("Author:")} ${account.gitUserName} <${account.email}>`,
1077
+ ` ${pc3.bold("SSH Host:")} ${pc3.cyan(account.ssh.hostAlias)}`
1078
+ ].join("\n");
1079
+ }
1080
+
1081
+ // src/commands/account/add.ts
1082
+ function registerAccountAddCommand(accountCmd) {
1083
+ accountCmd.command("add").description("Add and configure a new GitHub account profile").option("-a, --alias <id>", "Account alias identifier (e.g. work, personal)").option("-u, --username <username>", "GitHub username").option("-e, --email <email>", "Git commit email").option("-g, --git-name <name>", "Git author name (defaults to username)").option("-k, --key-path <path>", "Path to existing private SSH key").option("--key-type <type>", "SSH key type (ed25519 or rsa)", "ed25519").option("--no-keygen", "Do not generate a new SSH key").option("--global", "Set as default global Git identity").option("--json", "Output result in JSON format").action(async (options) => {
1084
+ const manager = new AccountManager();
1085
+ const sshService = new SshService();
1086
+ let input;
1087
+ if (options.alias && options.username && options.email) {
1088
+ input = {
1089
+ id: options.alias,
1090
+ username: options.username,
1091
+ email: options.email,
1092
+ gitUserName: options.gitName || options.username,
1093
+ sshKeyPath: options.keyPath,
1094
+ generateKey: options.keygen !== false && !options.keyPath,
1095
+ keyType: options.keyType === "rsa" ? "rsa" : "ed25519",
1096
+ setAsGlobal: Boolean(options.global)
1097
+ };
1098
+ } else {
1099
+ input = await promptAddAccount();
1100
+ }
1101
+ const spinner = ora("Configuring GitHub account & SSH host...").start();
1102
+ try {
1103
+ const profile = await manager.addAccount(input);
1104
+ spinner.succeed(`Account '${profile.id}' (@${profile.username}) added successfully!`);
1105
+ const pubKeyContent = sshService.getPublicKey(profile.ssh.keyPath);
1106
+ if (options.json) {
1107
+ console.log(JSON.stringify({ ...profile, publicKeyContent: pubKeyContent }, null, 2));
1108
+ return;
1109
+ }
1110
+ const guide = formatPublicKeyGuide(profile, pubKeyContent);
1111
+ logger.box(guide, "GitHub SSH Setup Required", "green");
1112
+ } catch (err) {
1113
+ const errorMsg = err instanceof Error ? err.message : String(err);
1114
+ spinner.fail(`Failed to add account: ${errorMsg}`);
1115
+ process.exit(1);
1116
+ }
1117
+ });
1118
+ }
1119
+
1120
+ // src/ui/table.ts
1121
+ import Table from "cli-table3";
1122
+ import pc4 from "picocolors";
1123
+ function renderAccountTable(accounts, activeGlobalAlias, activeLocalAlias) {
1124
+ if (accounts.length === 0) {
1125
+ return pc4.dim('No accounts configured yet. Run "omx account add" to get started.');
1126
+ }
1127
+ const table = new Table({
1128
+ head: [
1129
+ pc4.bold("Active"),
1130
+ pc4.bold("Alias"),
1131
+ pc4.bold("GitHub User"),
1132
+ pc4.bold("Git Commit Email"),
1133
+ pc4.bold("Git Author Name"),
1134
+ pc4.bold("SSH Host Alias")
1135
+ ],
1136
+ style: {
1137
+ head: [],
1138
+ border: ["dim"]
1139
+ }
1140
+ });
1141
+ for (const acc of accounts) {
1142
+ const isGlobal = activeGlobalAlias === acc.id || acc.isDefaultGlobal;
1143
+ const isLocal = activeLocalAlias === acc.id;
1144
+ let statusTag = "";
1145
+ if (isGlobal && isLocal) {
1146
+ statusTag = pc4.green("\u2605 Global & Local");
1147
+ } else if (isLocal) {
1148
+ statusTag = pc4.cyan("\u25CF Local");
1149
+ } else if (isGlobal) {
1150
+ statusTag = pc4.green("\u2605 Global");
1151
+ } else {
1152
+ statusTag = pc4.dim("\u2014");
1153
+ }
1154
+ table.push([
1155
+ statusTag,
1156
+ pc4.bold(pc4.yellow(acc.id)),
1157
+ acc.username,
1158
+ acc.email,
1159
+ acc.gitUserName,
1160
+ pc4.cyan(acc.ssh.hostAlias)
1161
+ ]);
1162
+ }
1163
+ return table.toString();
1164
+ }
1165
+
1166
+ // src/commands/account/list.ts
1167
+ function registerAccountListCommand(programOrAccountCmd) {
1168
+ const handler = async (options) => {
1169
+ const manager = new AccountManager();
1170
+ const gitService = new GitService();
1171
+ const accounts = manager.listAccounts();
1172
+ if (options.json) {
1173
+ console.log(JSON.stringify(accounts, null, 2));
1174
+ return;
1175
+ }
1176
+ const activeGlobal = manager.getAccount(manager.listAccounts().find((a) => a.isDefaultGlobal)?.id || "");
1177
+ let activeLocalAlias;
1178
+ const isRepo = await gitService.isInsideGitRepo();
1179
+ if (isRepo) {
1180
+ const localIdentity = await gitService.getIdentity("local");
1181
+ if (localIdentity.email) {
1182
+ const match = accounts.find((a) => a.email.toLowerCase() === localIdentity.email?.toLowerCase());
1183
+ if (match) {
1184
+ activeLocalAlias = match.id;
1185
+ }
1186
+ }
1187
+ }
1188
+ console.log(renderAccountTable(accounts, activeGlobal?.id, activeLocalAlias));
1189
+ };
1190
+ programOrAccountCmd.command("list").alias("ls").description("List all configured GitHub accounts").option("--json", "Output list as JSON").action(handler);
1191
+ }
1192
+
1193
+ // src/commands/account/edit.ts
1194
+ import ora2 from "ora";
1195
+ function registerAccountEditCommand(accountCmd) {
1196
+ accountCmd.command("edit [alias]").description("Update an existing GitHub account profile").option("-u, --username <username>", "Update GitHub username").option("-e, --email <email>", "Update Git commit email").option("-g, --git-name <name>", "Update Git author name").option("-k, --key-path <path>", "Update private SSH key path").option("--json", "Output updated profile in JSON format").action(async (aliasArg, options) => {
1197
+ const manager = new AccountManager();
1198
+ const accounts = manager.listAccounts();
1199
+ if (accounts.length === 0) {
1200
+ logger.warn('No accounts configured yet. Run "omx account add" first.');
1201
+ return;
1202
+ }
1203
+ let alias = aliasArg;
1204
+ let existing = alias ? manager.getAccount(alias) : void 0;
1205
+ if (!existing) {
1206
+ existing = await promptSelectAccount(accounts, "Select an account to edit:");
1207
+ alias = existing.id;
1208
+ }
1209
+ let updateInput;
1210
+ if (options.username || options.email || options.gitName || options.keyPath) {
1211
+ updateInput = {
1212
+ username: options.username,
1213
+ email: options.email,
1214
+ gitUserName: options.gitName,
1215
+ sshKeyPath: options.keyPath
1216
+ };
1217
+ } else {
1218
+ updateInput = await promptEditAccount(existing);
1219
+ }
1220
+ const spinner = ora2(`Updating account '${alias}'...`).start();
1221
+ try {
1222
+ const updated = await manager.updateAccount(alias, updateInput);
1223
+ spinner.succeed(`Account '${alias}' updated successfully!`);
1224
+ if (options.json) {
1225
+ console.log(JSON.stringify(updated, null, 2));
1226
+ } else {
1227
+ logger.highlight("Username", updated.username);
1228
+ logger.highlight("Email", updated.email);
1229
+ logger.highlight("Git Author", updated.gitUserName);
1230
+ logger.highlight("SSH Host", updated.ssh.hostAlias);
1231
+ }
1232
+ } catch (err) {
1233
+ const errorMsg = err instanceof Error ? err.message : String(err);
1234
+ spinner.fail(`Failed to update account: ${errorMsg}`);
1235
+ process.exit(1);
1236
+ }
1237
+ });
1238
+ }
1239
+
1240
+ // src/commands/account/remove.ts
1241
+ import ora3 from "ora";
1242
+ function registerAccountRemoveCommand(accountCmd) {
1243
+ accountCmd.command("remove [alias]").alias("rm").description("Remove a GitHub account profile").option("-d, --delete-keys", "Also delete the associated SSH private and public key files").option("-y, --yes", "Skip confirmation prompt").action(async (aliasArg, options) => {
1244
+ const manager = new AccountManager();
1245
+ const accounts = manager.listAccounts();
1246
+ if (accounts.length === 0) {
1247
+ logger.warn("No accounts configured to remove.");
1248
+ return;
1249
+ }
1250
+ let alias = aliasArg;
1251
+ let targetAccount = alias ? manager.getAccount(alias) : void 0;
1252
+ if (!targetAccount) {
1253
+ targetAccount = await promptSelectAccount(accounts, "Select an account to remove:");
1254
+ alias = targetAccount.id;
1255
+ }
1256
+ if (!options.yes) {
1257
+ const confirmed = await promptConfirm(
1258
+ `Are you sure you want to remove account profile '${alias}' (@${targetAccount.username})?`,
1259
+ false
1260
+ );
1261
+ if (!confirmed) {
1262
+ logger.info("Operation aborted.");
1263
+ return;
1264
+ }
1265
+ }
1266
+ const spinner = ora3(`Removing account '${alias}'...`).start();
1267
+ try {
1268
+ const removed = manager.removeAccount(alias, Boolean(options.deleteKeys));
1269
+ if (removed) {
1270
+ spinner.succeed(`Account profile '${alias}' removed successfully.`);
1271
+ if (options.deleteKeys) {
1272
+ logger.dim(`SSH keys at ${targetAccount.ssh.keyPath} were deleted.`);
1273
+ }
1274
+ } else {
1275
+ spinner.fail(`Account profile '${alias}' was not found.`);
1276
+ }
1277
+ } catch (err) {
1278
+ const errorMsg = err instanceof Error ? err.message : String(err);
1279
+ spinner.fail(`Failed to remove account: ${errorMsg}`);
1280
+ process.exit(1);
1281
+ }
1282
+ });
1283
+ }
1284
+
1285
+ // src/commands/account/test.ts
1286
+ import ora4 from "ora";
1287
+ function registerAccountTestCommand(accountCmd) {
1288
+ accountCmd.command("test [alias]").description("Test SSH connectivity and authentication for an account").option("--all", "Test all configured accounts").option("--json", "Output results in JSON format").action(async (aliasArg, options) => {
1289
+ const manager = new AccountManager();
1290
+ const accounts = manager.listAccounts();
1291
+ if (accounts.length === 0) {
1292
+ logger.warn('No accounts configured yet. Run "omx account add" first.');
1293
+ return;
1294
+ }
1295
+ if (options.all) {
1296
+ const results = [];
1297
+ for (const acc of accounts) {
1298
+ const spinner2 = ora4(`Testing SSH connection for '${acc.id}' (${acc.ssh.hostAlias})...`).start();
1299
+ const result2 = await manager.testAccount(acc.id);
1300
+ if (result2.authenticated) {
1301
+ spinner2.succeed(`Account '${acc.id}' authenticated successfully.`);
1302
+ } else {
1303
+ spinner2.fail(`Account '${acc.id}' authentication failed.`);
1304
+ }
1305
+ results.push(result2);
1306
+ }
1307
+ if (options.json) {
1308
+ console.log(JSON.stringify(results, null, 2));
1309
+ } else {
1310
+ for (const res of results) {
1311
+ console.log("\n" + formatTestResult(res));
1312
+ }
1313
+ }
1314
+ return;
1315
+ }
1316
+ let alias = aliasArg;
1317
+ if (!alias) {
1318
+ const selected = await promptSelectAccount(accounts, "Select an account to test SSH connection:");
1319
+ alias = selected.id;
1320
+ }
1321
+ const targetAccount = manager.getAccount(alias);
1322
+ if (!targetAccount) {
1323
+ logger.error(`Account profile '${alias}' does not exist.`);
1324
+ process.exit(1);
1325
+ }
1326
+ const spinner = ora4(`Testing SSH connection to ${targetAccount.ssh.hostAlias}...`).start();
1327
+ const result = await manager.testAccount(alias);
1328
+ if (result.authenticated) {
1329
+ spinner.succeed(`SSH Authentication successful!`);
1330
+ } else {
1331
+ spinner.fail(`SSH Authentication failed.`);
1332
+ }
1333
+ if (options.json) {
1334
+ console.log(JSON.stringify(result, null, 2));
1335
+ } else {
1336
+ console.log("\n" + formatTestResult(result));
1337
+ }
1338
+ });
1339
+ }
1340
+
1341
+ // src/commands/account/import.ts
1342
+ import path4 from "path";
1343
+ import * as p2 from "@clack/prompts";
1344
+ import ora5 from "ora";
1345
+ import pc5 from "picocolors";
1346
+ function registerAccountImportCommand(accountCmd) {
1347
+ accountCmd.command("import [keyPath]").description("Automatically discover or import an existing SSH key as an octomux account").option("-a, --alias <id>", "Account alias identifier").option("-u, --username <username>", "GitHub username").option("-e, --email <email>", "Git commit email").option("-g, --git-name <name>", "Git author name").option("--global", "Set as default global Git identity").action(async (keyPathArg, options) => {
1348
+ const manager = new AccountManager();
1349
+ const sshService = new SshService();
1350
+ let targetKeyPath = keyPathArg;
1351
+ let initialEmail = options.email || "";
1352
+ let initialUsername = options.username || "";
1353
+ if (!targetKeyPath) {
1354
+ p2.intro(pc5.bold(pc5.cyan("octomux (omx) \u2014 Auto-Import Existing SSH Key")));
1355
+ const discovered = sshService.scanExistingSshKeys();
1356
+ if (discovered.length === 0) {
1357
+ logger.warn('No existing SSH keys found in ~/.ssh. Use "omx account add" to generate one.');
1358
+ return;
1359
+ }
1360
+ const selectedKey = await p2.select({
1361
+ message: "Select an existing SSH key to import:",
1362
+ options: discovered.map((k) => {
1363
+ const comment = k.comment ? ` (${k.comment})` : "";
1364
+ return {
1365
+ value: k.privateKeyPath,
1366
+ label: `${pc5.bold(k.name)} [${k.keyType}]${comment}`
1367
+ };
1368
+ })
1369
+ });
1370
+ handleCancel(selectedKey);
1371
+ targetKeyPath = selectedKey;
1372
+ const match = discovered.find((k) => k.privateKeyPath === targetKeyPath);
1373
+ if (match?.comment) {
1374
+ if (match.comment.includes("@")) {
1375
+ initialEmail = match.comment;
1376
+ } else {
1377
+ initialUsername = match.comment;
1378
+ }
1379
+ }
1380
+ }
1381
+ const basename = path4.basename(targetKeyPath).replace(/^id_(ed25519|rsa|ecdsa)_?/, "") || "profile";
1382
+ const defaultAlias = options.alias || (basename !== "profile" ? basename : "imported");
1383
+ const alias = options.alias || await p2.text({
1384
+ message: "Account alias ID (e.g. work, personal):",
1385
+ initialValue: defaultAlias,
1386
+ validate: (val) => !val || !val.trim() ? "Alias is required" : void 0
1387
+ });
1388
+ handleCancel(alias);
1389
+ const username = options.username || await p2.text({
1390
+ message: "GitHub username:",
1391
+ initialValue: initialUsername,
1392
+ validate: (val) => !val || !val.trim() ? "Username is required" : void 0
1393
+ });
1394
+ handleCancel(username);
1395
+ const email = options.email || await p2.text({
1396
+ message: "Git commit email:",
1397
+ initialValue: initialEmail,
1398
+ validate: (val) => {
1399
+ if (!val || !val.trim()) return "Email is required";
1400
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return "Invalid email address";
1401
+ return void 0;
1402
+ }
1403
+ });
1404
+ handleCancel(email);
1405
+ const gitUserName = options.gitName || await p2.text({
1406
+ message: "Git author name:",
1407
+ initialValue: username
1408
+ });
1409
+ handleCancel(gitUserName);
1410
+ const setAsGlobal = options.global !== void 0 ? Boolean(options.global) : await p2.confirm({
1411
+ message: "Set as active global Git identity?",
1412
+ initialValue: false
1413
+ });
1414
+ handleCancel(setAsGlobal);
1415
+ const spinner = ora5("Importing key and configuring SSH host...").start();
1416
+ try {
1417
+ const profile = await manager.addAccount({
1418
+ id: alias.trim(),
1419
+ username: username.trim(),
1420
+ email: email.trim(),
1421
+ gitUserName: (gitUserName || username).trim(),
1422
+ sshKeyPath: targetKeyPath,
1423
+ generateKey: false,
1424
+ setAsGlobal: Boolean(setAsGlobal)
1425
+ });
1426
+ spinner.succeed(`Account '${profile.id}' imported successfully with key: ${path4.basename(targetKeyPath)}`);
1427
+ const pubKeyContent = sshService.getPublicKey(profile.ssh.keyPath);
1428
+ const guide = formatPublicKeyGuide(profile, pubKeyContent);
1429
+ logger.box(guide, "Account Imported", "green");
1430
+ } catch (err) {
1431
+ const errorMsg = err instanceof Error ? err.message : String(err);
1432
+ spinner.fail(`Failed to import account: ${errorMsg}`);
1433
+ process.exit(1);
1434
+ }
1435
+ });
1436
+ }
1437
+
1438
+ // src/commands/account/index.ts
1439
+ function registerAccountCommands(program) {
1440
+ const accountCmd = program.command("account").alias("acc").description("Manage GitHub account profiles and SSH configurations");
1441
+ registerAccountAddCommand(accountCmd);
1442
+ registerAccountListCommand(accountCmd);
1443
+ registerAccountEditCommand(accountCmd);
1444
+ registerAccountRemoveCommand(accountCmd);
1445
+ registerAccountTestCommand(accountCmd);
1446
+ registerAccountImportCommand(accountCmd);
1447
+ registerAccountListCommand(program);
1448
+ registerAccountImportCommand(program);
1449
+ }
1450
+
1451
+ // src/commands/switch.ts
1452
+ import * as p3 from "@clack/prompts";
1453
+ function registerSwitchCommand(program) {
1454
+ program.command("switch [alias]").alias("use").description("Switch active Git identity locally in current repository or globally").option("-g, --global", "Apply switch to global Git configuration").option("-l, --local", "Apply switch to local repository Git configuration").action(async (aliasArg, options) => {
1455
+ const manager = new AccountManager();
1456
+ const gitService = new GitService();
1457
+ const accounts = manager.listAccounts();
1458
+ if (accounts.length === 0) {
1459
+ logger.warn('No accounts configured yet. Run "omx account add" first.');
1460
+ return;
1461
+ }
1462
+ let targetAccount = aliasArg ? manager.getAccount(aliasArg) : void 0;
1463
+ if (!targetAccount) {
1464
+ targetAccount = await promptSelectAccount(accounts, "Select account to switch to:");
1465
+ }
1466
+ let isGlobal = Boolean(options.global);
1467
+ let isLocal = Boolean(options.local);
1468
+ const inRepo = await gitService.isInsideGitRepo();
1469
+ if (!isGlobal && !isLocal) {
1470
+ if (inRepo) {
1471
+ const scope = await p3.select({
1472
+ message: `Where do you want to apply account '${targetAccount.id}'?`,
1473
+ options: [
1474
+ { value: "local", label: "Local Repository (Current folder only, with dedicated SSH key)" },
1475
+ { value: "global", label: "Global (System-wide default Git user)" },
1476
+ { value: "both", label: "Both Local Repository & Global" }
1477
+ ]
1478
+ });
1479
+ handleCancel(scope);
1480
+ if (scope === "local" || scope === "both") isLocal = true;
1481
+ if (scope === "global" || scope === "both") isGlobal = true;
1482
+ } else {
1483
+ isGlobal = true;
1484
+ }
1485
+ }
1486
+ if (isGlobal) {
1487
+ await manager.switchGlobal(targetAccount.id);
1488
+ logger.success(`Global Git identity switched to: ${targetAccount.gitUserName} <${targetAccount.email}>`);
1489
+ }
1490
+ if (isLocal) {
1491
+ if (!inRepo) {
1492
+ logger.error("Cannot apply local config: Current directory is not inside a Git repository.");
1493
+ process.exit(1);
1494
+ }
1495
+ await manager.switchLocal(targetAccount.id);
1496
+ logger.success(`Local repository Git identity switched to: ${targetAccount.gitUserName} <${targetAccount.email}>`);
1497
+ logger.dim(`SSH Key bound: ${targetAccount.ssh.keyPath}`);
1498
+ }
1499
+ });
1500
+ }
1501
+
1502
+ // src/commands/status.ts
1503
+ import pc6 from "picocolors";
1504
+ function registerStatusCommand(program) {
1505
+ const handler = async (options) => {
1506
+ const manager = new AccountManager();
1507
+ const gitService = new GitService();
1508
+ const accounts = manager.listAccounts();
1509
+ const globalIdentity = await gitService.getIdentity("global");
1510
+ const inRepo = await gitService.isInsideGitRepo();
1511
+ let localIdentity;
1512
+ let repoRoot;
1513
+ let remoteUrl;
1514
+ if (inRepo) {
1515
+ localIdentity = await gitService.getIdentity("local");
1516
+ repoRoot = await gitService.getRepoRoot();
1517
+ remoteUrl = await gitService.getRemoteUrl();
1518
+ }
1519
+ const matchedGlobal = accounts.find(
1520
+ (a) => a.email.toLowerCase() === globalIdentity.email?.toLowerCase()
1521
+ );
1522
+ const matchedLocal = localIdentity?.email ? accounts.find((a) => a.email.toLowerCase() === localIdentity?.email?.toLowerCase()) : void 0;
1523
+ if (options.json) {
1524
+ console.log(
1525
+ JSON.stringify(
1526
+ {
1527
+ inRepo,
1528
+ repoRoot,
1529
+ remoteUrl,
1530
+ global: {
1531
+ ...globalIdentity,
1532
+ matchedAccount: matchedGlobal?.id
1533
+ },
1534
+ local: inRepo ? {
1535
+ ...localIdentity,
1536
+ matchedAccount: matchedLocal?.id
1537
+ } : null
1538
+ },
1539
+ null,
1540
+ 2
1541
+ )
1542
+ );
1543
+ return;
1544
+ }
1545
+ const lines = [];
1546
+ lines.push(pc6.bold(pc6.cyan("\u25CF Global Git Configuration:")));
1547
+ lines.push(
1548
+ ` Author: ${globalIdentity.name || pc6.dim("(not set)")} <${globalIdentity.email || pc6.dim("(not set)")}>`
1549
+ );
1550
+ if (matchedGlobal) {
1551
+ lines.push(` Profile: ${pc6.yellow(matchedGlobal.id)} (@${matchedGlobal.username})`);
1552
+ } else {
1553
+ lines.push(` Profile: ${pc6.dim("No matching octomux profile")}`);
1554
+ }
1555
+ lines.push("");
1556
+ if (inRepo) {
1557
+ lines.push(pc6.bold(pc6.green("\u25CF Current Local Repository:")));
1558
+ lines.push(` Path: ${repoRoot}`);
1559
+ if (remoteUrl) {
1560
+ lines.push(` Remote: ${pc6.dim(remoteUrl)}`);
1561
+ }
1562
+ lines.push(
1563
+ ` Author: ${localIdentity?.name || pc6.dim("(inherited from global)")} <${localIdentity?.email || pc6.dim("(inherited from global)")}>`
1564
+ );
1565
+ if (localIdentity?.sshCommand) {
1566
+ lines.push(` SSH: ${pc6.dim(localIdentity.sshCommand)}`);
1567
+ }
1568
+ if (matchedLocal) {
1569
+ lines.push(` Profile: ${pc6.yellow(matchedLocal.id)} (@${matchedLocal.username})`);
1570
+ }
1571
+ } else {
1572
+ lines.push(pc6.bold(pc6.dim("\u25CF Current Directory is not a Git repository.")));
1573
+ }
1574
+ logger.box(lines.join("\n"), "Git & Identity Status", inRepo ? "green" : "cyan");
1575
+ };
1576
+ program.command("status").alias("current").description("Show active Git identities (global & local repository) and profile match").option("--json", "Output status as JSON").action(handler);
1577
+ }
1578
+
1579
+ // src/commands/clone.ts
1580
+ import path5 from "path";
1581
+ import fs5 from "fs";
1582
+ import ora6 from "ora";
1583
+ function registerCloneCommand(program) {
1584
+ program.command("clone <repo> [directory]").description("Smart clone a GitHub repository and automatically configure local identity").option("-a, --account <alias>", "Account profile to clone and bind with").option("--json", "Output clone result as JSON").allowUnknownOption(false).action(async (repoInput, directoryArg, options) => {
1585
+ const manager = new AccountManager();
1586
+ const gitService = new GitService();
1587
+ const accounts = manager.listAccounts();
1588
+ if (accounts.length === 0) {
1589
+ logger.error('No accounts configured in octomux yet. Please run "omx account add" first.');
1590
+ process.exit(1);
1591
+ }
1592
+ const parsedRepo = gitService.parseRepoInput(repoInput);
1593
+ if (!parsedRepo) {
1594
+ logger.error(
1595
+ `Invalid repository format: "${repoInput}".
1596
+ Supported formats:
1597
+ - owner/repo
1598
+ - https://github.com/owner/repo.git
1599
+ - git@github.com:owner/repo.git`
1600
+ );
1601
+ process.exit(1);
1602
+ }
1603
+ let account;
1604
+ if (options.account) {
1605
+ account = manager.getAccount(options.account);
1606
+ if (!account) {
1607
+ logger.error(`Account profile '${options.account}' not found.`);
1608
+ process.exit(1);
1609
+ }
1610
+ } else if (accounts.length === 1 && accounts[0]) {
1611
+ account = accounts[0];
1612
+ } else {
1613
+ account = await promptSelectAccount(
1614
+ accounts,
1615
+ `Select the account to clone ${parsedRepo.owner}/${parsedRepo.repo}:`
1616
+ );
1617
+ }
1618
+ const sshCloneUrl = gitService.formatSshCloneUrl(
1619
+ account.ssh.hostAlias,
1620
+ parsedRepo.owner,
1621
+ parsedRepo.repo
1622
+ );
1623
+ logger.info(`Cloning with profile ${account.id} using SSH host: ${account.ssh.hostAlias}`);
1624
+ try {
1625
+ const clonedDirName = await gitService.clone(sshCloneUrl, directoryArg);
1626
+ const resolvedPath = path5.resolve(process.cwd(), clonedDirName);
1627
+ if (fs5.existsSync(resolvedPath)) {
1628
+ const spinner = ora6("Binding local Git author & SSH key configuration...").start();
1629
+ await gitService.setLocalIdentity(
1630
+ account.gitUserName,
1631
+ account.email,
1632
+ account.ssh.keyPath,
1633
+ resolvedPath
1634
+ );
1635
+ spinner.succeed("Local Git identity configured!");
1636
+ if (options.json) {
1637
+ console.log(
1638
+ JSON.stringify(
1639
+ {
1640
+ success: true,
1641
+ repository: `${parsedRepo.owner}/${parsedRepo.repo}`,
1642
+ path: resolvedPath,
1643
+ account: account.id,
1644
+ gitUserName: account.gitUserName,
1645
+ email: account.email,
1646
+ sshKey: account.ssh.keyPath
1647
+ },
1648
+ null,
1649
+ 2
1650
+ )
1651
+ );
1652
+ } else {
1653
+ const summary = formatCloneSummary(
1654
+ `${parsedRepo.owner}/${parsedRepo.repo}`,
1655
+ resolvedPath,
1656
+ account
1657
+ );
1658
+ logger.box(summary, "Smart Clone Complete", "green");
1659
+ }
1660
+ }
1661
+ } catch (err) {
1662
+ const errorMsg = err instanceof Error ? err.message : String(err);
1663
+ logger.error(`Git clone failed: ${errorMsg}`);
1664
+ process.exit(1);
1665
+ }
1666
+ });
1667
+ }
1668
+
1669
+ // src/commands/remote.ts
1670
+ import * as p4 from "@clack/prompts";
1671
+ import pc7 from "picocolors";
1672
+ function registerRemoteCommand(program) {
1673
+ program.command("remote [repo]").alias("origin").description("Add or update Git remote origin and automatically bind local identity").option("-a, --account <alias>", "Account profile to bind with this remote").option("-r, --remote <name>", "Remote name", "origin").option("--init", "Initialize a new Git repository if not already one").option("--json", "Output result in JSON format").action(async (repoArg, options) => {
1674
+ const manager = new AccountManager();
1675
+ const gitService = new GitService();
1676
+ const accounts = manager.listAccounts();
1677
+ if (accounts.length === 0) {
1678
+ logger.error('No accounts configured in octomux yet. Run "omx account add" first.');
1679
+ process.exit(1);
1680
+ }
1681
+ let inRepo = await gitService.isInsideGitRepo();
1682
+ if (!inRepo) {
1683
+ if (options.init) {
1684
+ await gitService.initRepo();
1685
+ logger.success("Initialized new Git repository in current directory.");
1686
+ inRepo = true;
1687
+ } else {
1688
+ const shouldInit = await promptConfirm(
1689
+ "Current directory is not a Git repository. Initialize git repository now?",
1690
+ true
1691
+ );
1692
+ if (shouldInit) {
1693
+ await gitService.initRepo();
1694
+ logger.success("Initialized new Git repository in current directory.");
1695
+ inRepo = true;
1696
+ } else {
1697
+ logger.error("Operation aborted: Not a Git repository.");
1698
+ process.exit(1);
1699
+ }
1700
+ }
1701
+ }
1702
+ let repoInput = repoArg;
1703
+ if (!repoInput) {
1704
+ const input = await p4.text({
1705
+ message: "Enter GitHub repository (e.g. owner/repo, https://github.com/owner/repo, or git@...):",
1706
+ placeholder: "username/repo-name",
1707
+ validate: (val) => !val || !val.trim() ? "Repository is required" : void 0
1708
+ });
1709
+ handleCancel(input);
1710
+ repoInput = input;
1711
+ }
1712
+ const parsedRepo = gitService.parseRepoInput(repoInput);
1713
+ if (!parsedRepo) {
1714
+ logger.error(
1715
+ `Invalid repository format: "${repoInput}".
1716
+ Supported formats:
1717
+ - owner/repo
1718
+ - https://github.com/owner/repo.git
1719
+ - git@github.com:owner/repo.git`
1720
+ );
1721
+ process.exit(1);
1722
+ }
1723
+ let account;
1724
+ if (options.account) {
1725
+ account = manager.getAccount(options.account);
1726
+ if (!account) {
1727
+ logger.error(`Account profile '${options.account}' not found.`);
1728
+ process.exit(1);
1729
+ }
1730
+ } else if (accounts.length === 1 && accounts[0]) {
1731
+ account = accounts[0];
1732
+ } else {
1733
+ account = await promptSelectAccount(
1734
+ accounts,
1735
+ `Select the account profile for remote '${options.remote}':`
1736
+ );
1737
+ }
1738
+ const sshRemoteUrl = gitService.formatSshCloneUrl(
1739
+ account.ssh.hostAlias,
1740
+ parsedRepo.owner,
1741
+ parsedRepo.repo
1742
+ );
1743
+ const remoteName = options.remote || "origin";
1744
+ const existingUrl = await gitService.getRemoteUrl(remoteName);
1745
+ if (existingUrl) {
1746
+ await gitService.setRemoteUrl(remoteName, sshRemoteUrl);
1747
+ logger.success(`Updated existing remote '${remoteName}' URL.`);
1748
+ } else {
1749
+ await gitService.addRemoteUrl(remoteName, sshRemoteUrl);
1750
+ logger.success(`Added remote '${remoteName}'.`);
1751
+ }
1752
+ await gitService.setLocalIdentity(
1753
+ account.gitUserName,
1754
+ account.email,
1755
+ account.ssh.keyPath
1756
+ );
1757
+ if (options.json) {
1758
+ console.log(
1759
+ JSON.stringify(
1760
+ {
1761
+ success: true,
1762
+ remote: remoteName,
1763
+ url: sshRemoteUrl,
1764
+ account: account.id,
1765
+ gitUserName: account.gitUserName,
1766
+ email: account.email,
1767
+ sshKey: account.ssh.keyPath
1768
+ },
1769
+ null,
1770
+ 2
1771
+ )
1772
+ );
1773
+ return;
1774
+ }
1775
+ const summaryLines = [
1776
+ pc7.bold(pc7.green(`Remote '${remoteName}' configured successfully!`)),
1777
+ "",
1778
+ ` ${pc7.bold("Remote URL:")} ${pc7.cyan(sshRemoteUrl)}`,
1779
+ ` ${pc7.bold("Account:")} ${pc7.yellow(account.id)} (@${account.username})`,
1780
+ ` ${pc7.bold("Author:")} ${account.gitUserName} <${account.email}>`,
1781
+ ` ${pc7.bold("SSH Host:")} ${pc7.cyan(account.ssh.hostAlias)}`,
1782
+ "",
1783
+ pc7.dim("You can now push with: git push -u " + remoteName + " main")
1784
+ ];
1785
+ logger.box(summaryLines.join("\n"), "Remote Origin Configured", "green");
1786
+ });
1787
+ }
1788
+
1789
+ // src/commands/uninstall.ts
1790
+ import fs6 from "fs";
1791
+ import path6 from "path";
1792
+ import ora7 from "ora";
1793
+ function registerUninstallCommand(program) {
1794
+ program.command("uninstall").alias("purge").description("Safely clean up octomux configurations, SSH blocks, and local data").option("--delete-keys", "Also delete octomux-generated SSH private & public keys").option("-y, --yes", "Skip interactive confirmation").action(async (options) => {
1795
+ const configStore = new ConfigStore();
1796
+ const sshService = new SshService();
1797
+ logger.warn("This will safely remove octomux configurations from your system.");
1798
+ if (!options.yes) {
1799
+ const confirmed = await promptConfirm(
1800
+ "Are you sure you want to clean up all octomux managed configurations?",
1801
+ false
1802
+ );
1803
+ if (!confirmed) {
1804
+ logger.info("Uninstall operation aborted.");
1805
+ return;
1806
+ }
1807
+ }
1808
+ let deleteKeys = Boolean(options.deleteKeys);
1809
+ if (!options.yes && !options.deleteKeys) {
1810
+ deleteKeys = await promptConfirm(
1811
+ "Do you also want to delete all SSH keys generated by octomux (~/.ssh/id_*_octomux_*)?",
1812
+ false
1813
+ );
1814
+ }
1815
+ const spinner = ora7("Cleaning up octomux configurations...").start();
1816
+ try {
1817
+ sshService.syncSshConfig([]);
1818
+ spinner.text = "Cleaned octomux managed block in ~/.ssh/config...";
1819
+ if (deleteKeys) {
1820
+ const accounts = Object.values(configStore.getAccounts());
1821
+ for (const acc of accounts) {
1822
+ if (fs6.existsSync(acc.ssh.keyPath)) {
1823
+ try {
1824
+ fs6.unlinkSync(acc.ssh.keyPath);
1825
+ } catch {
1826
+ }
1827
+ }
1828
+ if (fs6.existsSync(acc.ssh.publicKeyPath)) {
1829
+ try {
1830
+ fs6.unlinkSync(acc.ssh.publicKeyPath);
1831
+ } catch {
1832
+ }
1833
+ }
1834
+ }
1835
+ const sshDir = getSshDir();
1836
+ if (fs6.existsSync(sshDir)) {
1837
+ const files = fs6.readdirSync(sshDir);
1838
+ for (const file of files) {
1839
+ if (file.includes("_octomux_")) {
1840
+ try {
1841
+ fs6.unlinkSync(path6.join(sshDir, file));
1842
+ } catch {
1843
+ }
1844
+ }
1845
+ }
1846
+ }
1847
+ }
1848
+ const octomuxDir = getOctomuxDir();
1849
+ if (fs6.existsSync(octomuxDir)) {
1850
+ fs6.rmSync(octomuxDir, { recursive: true, force: true });
1851
+ }
1852
+ spinner.succeed("octomux configurations cleaned up successfully!");
1853
+ const lines = [
1854
+ "octomux data has been safely uninstalled:",
1855
+ " \u2714 Cleaned managed entries from ~/.ssh/config (custom user hosts preserved)",
1856
+ deleteKeys ? " \u2714 Deleted octomux-generated SSH keys" : " \u2139 Preserved SSH keys in ~/.ssh/",
1857
+ " \u2714 Removed ~/.octomux configuration store",
1858
+ "",
1859
+ "To remove the npm binary package, run:",
1860
+ ' npm uninstall -g octomux (or "npm unlink -g octomux" for dev builds)'
1861
+ ];
1862
+ logger.box(lines.join("\n"), "Safe Uninstall Complete", "green");
1863
+ } catch (err) {
1864
+ const errorMsg = err instanceof Error ? err.message : String(err);
1865
+ spinner.fail(`Failed to clean up: ${errorMsg}`);
1866
+ process.exit(1);
1867
+ }
1868
+ });
1869
+ }
1870
+
1871
+ // src/index.ts
1872
+ function createProgram() {
1873
+ const program = new Command();
1874
+ program.name("octomux").description("Enterprise-grade cross-platform GitHub multi-account & SSH identity manager").version("1.0.0", "-v, --version", "Output the current version of octomux");
1875
+ registerAccountCommands(program);
1876
+ registerSwitchCommand(program);
1877
+ registerStatusCommand(program);
1878
+ registerCloneCommand(program);
1879
+ registerRemoteCommand(program);
1880
+ registerUninstallCommand(program);
1881
+ program.action(async () => {
1882
+ p5.intro(pc8.bold(pc8.cyan("\u{1F419} octomux (omx) \u2014 GitHub Identity & SSH Manager")));
1883
+ const action = await p5.select({
1884
+ message: "What would you like to do?",
1885
+ options: [
1886
+ { value: "list", label: "\u{1F4CB} List configured accounts" },
1887
+ { value: "add", label: "\u2795 Add a new GitHub account" },
1888
+ { value: "import", label: "\u{1F511} Auto-import existing SSH keys" },
1889
+ { value: "switch", label: "\u{1F500} Switch Git identity (global / local)" },
1890
+ { value: "remote", label: "\u{1F517} Add or set remote origin for this repo" },
1891
+ { value: "clone", label: "\u{1F4E6} Smart clone a repository" },
1892
+ { value: "status", label: "\u{1F50D} Check active Git & SSH status" },
1893
+ { value: "test", label: "\u26A1 Test SSH connections" },
1894
+ { value: "uninstall", label: "\u{1F5D1}\uFE0F Uninstall & clean up octomux" },
1895
+ { value: "exit", label: "\u{1F6AA} Exit" }
1896
+ ]
1897
+ });
1898
+ handleCancel(action);
1899
+ if (action === "exit") {
1900
+ p5.outro("Goodbye!");
1901
+ process.exit(0);
1902
+ }
1903
+ switch (action) {
1904
+ case "list":
1905
+ await program.parseAsync(["node", "octomux", "account", "list"]);
1906
+ break;
1907
+ case "add":
1908
+ await program.parseAsync(["node", "octomux", "account", "add"]);
1909
+ break;
1910
+ case "import":
1911
+ await program.parseAsync(["node", "octomux", "account", "import"]);
1912
+ break;
1913
+ case "switch":
1914
+ await program.parseAsync(["node", "octomux", "switch"]);
1915
+ break;
1916
+ case "remote": {
1917
+ const repo = await p5.text({
1918
+ message: "Enter GitHub repository (e.g. owner/repo, https://github.com/...):",
1919
+ validate: (val) => !val || !val.trim() ? "Repository is required" : void 0
1920
+ });
1921
+ handleCancel(repo);
1922
+ await program.parseAsync(["node", "octomux", "remote", repo]);
1923
+ break;
1924
+ }
1925
+ case "clone": {
1926
+ const repo = await p5.text({
1927
+ message: "Enter repository slug or URL (e.g. owner/repo):",
1928
+ validate: (val) => !val || !val.trim() ? "Repository is required" : void 0
1929
+ });
1930
+ handleCancel(repo);
1931
+ await program.parseAsync(["node", "octomux", "clone", repo]);
1932
+ break;
1933
+ }
1934
+ case "status":
1935
+ await program.parseAsync(["node", "octomux", "status"]);
1936
+ break;
1937
+ case "test":
1938
+ await program.parseAsync(["node", "octomux", "account", "test"]);
1939
+ break;
1940
+ case "uninstall":
1941
+ await program.parseAsync(["node", "octomux", "uninstall"]);
1942
+ break;
1943
+ }
1944
+ });
1945
+ return program;
1946
+ }
1947
+ async function run(argv = process.argv) {
1948
+ const program = createProgram();
1949
+ await program.parseAsync(argv);
1950
+ }
1951
+
1952
+ export {
1953
+ logger,
1954
+ createProgram,
1955
+ run
1956
+ };
1957
+ //# sourceMappingURL=chunk-3NCQOE6V.js.map