@fredlackey/devutils 0.0.18 → 0.0.19

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 (331) hide show
  1. package/README.md +32 -150
  2. package/package.json +5 -82
  3. package/bin/dev.js +0 -16
  4. package/files/README.md +0 -0
  5. package/files/claude/.claude/commands/setup-context.md +0 -3
  6. package/files/monorepos/_archive/README.md +0 -36
  7. package/files/monorepos/_legacy/README.md +0 -36
  8. package/files/monorepos/ai-docs/README.md +0 -33
  9. package/files/monorepos/apps/README.md +0 -24
  10. package/files/monorepos/docs/README.md +0 -40
  11. package/files/monorepos/packages/README.md +0 -25
  12. package/files/monorepos/research/README.md +0 -29
  13. package/files/monorepos/scripts/README.md +0 -24
  14. package/src/cli.js +0 -72
  15. package/src/commands/README.md +0 -41
  16. package/src/commands/configure.js +0 -199
  17. package/src/commands/identity.js +0 -1630
  18. package/src/commands/ignore.js +0 -247
  19. package/src/commands/install.js +0 -526
  20. package/src/commands/setup.js +0 -246
  21. package/src/commands/status.js +0 -223
  22. package/src/commands/update.js +0 -142
  23. package/src/commands/version.js +0 -100
  24. package/src/completion.js +0 -284
  25. package/src/constants.js +0 -45
  26. package/src/ignore/claude-code.txt +0 -10
  27. package/src/ignore/docker.txt +0 -18
  28. package/src/ignore/linux.txt +0 -23
  29. package/src/ignore/macos.txt +0 -36
  30. package/src/ignore/node.txt +0 -55
  31. package/src/ignore/terraform.txt +0 -37
  32. package/src/ignore/vscode.txt +0 -18
  33. package/src/ignore/windows.txt +0 -35
  34. package/src/index.js +0 -0
  35. package/src/installs/README.md +0 -399
  36. package/src/installs/adobe-creative-cloud.js +0 -546
  37. package/src/installs/adobe-creative-cloud.md +0 -605
  38. package/src/installs/appcleaner.js +0 -321
  39. package/src/installs/appcleaner.md +0 -699
  40. package/src/installs/apt-transport-https.js +0 -390
  41. package/src/installs/apt-transport-https.md +0 -678
  42. package/src/installs/atomicparsley.js +0 -642
  43. package/src/installs/atomicparsley.md +0 -795
  44. package/src/installs/aws-cli.js +0 -797
  45. package/src/installs/aws-cli.md +0 -727
  46. package/src/installs/balena-etcher.js +0 -710
  47. package/src/installs/balena-etcher.md +0 -761
  48. package/src/installs/bambu-studio.js +0 -1143
  49. package/src/installs/bambu-studio.md +0 -780
  50. package/src/installs/bash-completion.js +0 -575
  51. package/src/installs/bash-completion.md +0 -833
  52. package/src/installs/bash.js +0 -417
  53. package/src/installs/bash.md +0 -993
  54. package/src/installs/beyond-compare.js +0 -603
  55. package/src/installs/beyond-compare.md +0 -813
  56. package/src/installs/brave-browser.js +0 -968
  57. package/src/installs/brave-browser.md +0 -650
  58. package/src/installs/build-essential.js +0 -529
  59. package/src/installs/build-essential.md +0 -977
  60. package/src/installs/ca-certificates.js +0 -618
  61. package/src/installs/ca-certificates.md +0 -937
  62. package/src/installs/caffeine.js +0 -508
  63. package/src/installs/caffeine.md +0 -839
  64. package/src/installs/camtasia.js +0 -596
  65. package/src/installs/camtasia.md +0 -762
  66. package/src/installs/chatgpt.js +0 -476
  67. package/src/installs/chatgpt.md +0 -814
  68. package/src/installs/chocolatey.js +0 -456
  69. package/src/installs/chocolatey.md +0 -661
  70. package/src/installs/chrome-canary.js +0 -419
  71. package/src/installs/chrome-canary.md +0 -641
  72. package/src/installs/chromium.js +0 -667
  73. package/src/installs/chromium.md +0 -838
  74. package/src/installs/claude-code.js +0 -576
  75. package/src/installs/claude-code.md +0 -1173
  76. package/src/installs/cloudflare-warp.js +0 -900
  77. package/src/installs/cloudflare-warp.md +0 -1047
  78. package/src/installs/comet-browser.js +0 -588
  79. package/src/installs/comet-browser.md +0 -731
  80. package/src/installs/curl.js +0 -379
  81. package/src/installs/curl.md +0 -714
  82. package/src/installs/cursor.js +0 -579
  83. package/src/installs/cursor.md +0 -970
  84. package/src/installs/dbeaver.js +0 -924
  85. package/src/installs/dbeaver.md +0 -939
  86. package/src/installs/dbschema.js +0 -692
  87. package/src/installs/dbschema.md +0 -925
  88. package/src/installs/dependencies.md +0 -453
  89. package/src/installs/development-tools.js +0 -600
  90. package/src/installs/development-tools.md +0 -977
  91. package/src/installs/docker.js +0 -1029
  92. package/src/installs/docker.md +0 -1109
  93. package/src/installs/drawio.js +0 -1019
  94. package/src/installs/drawio.md +0 -795
  95. package/src/installs/elmedia-player.js +0 -347
  96. package/src/installs/elmedia-player.md +0 -556
  97. package/src/installs/ffmpeg.js +0 -889
  98. package/src/installs/ffmpeg.md +0 -852
  99. package/src/installs/file.js +0 -464
  100. package/src/installs/file.md +0 -987
  101. package/src/installs/gemini-cli.js +0 -811
  102. package/src/installs/gemini-cli.md +0 -1153
  103. package/src/installs/git.js +0 -400
  104. package/src/installs/git.md +0 -907
  105. package/src/installs/gitego.js +0 -949
  106. package/src/installs/gitego.md +0 -1172
  107. package/src/installs/go.js +0 -931
  108. package/src/installs/go.md +0 -958
  109. package/src/installs/google-antigravity.js +0 -913
  110. package/src/installs/google-antigravity.md +0 -1075
  111. package/src/installs/google-chrome.js +0 -833
  112. package/src/installs/google-chrome.md +0 -862
  113. package/src/installs/gpg.js +0 -480
  114. package/src/installs/gpg.md +0 -1056
  115. package/src/installs/homebrew.js +0 -1028
  116. package/src/installs/homebrew.md +0 -988
  117. package/src/installs/imageoptim.js +0 -968
  118. package/src/installs/imageoptim.md +0 -1119
  119. package/src/installs/installers.json +0 -4032
  120. package/src/installs/installers.json.tmp +0 -3953
  121. package/src/installs/jq.js +0 -400
  122. package/src/installs/jq.md +0 -809
  123. package/src/installs/keyboard-maestro.js +0 -719
  124. package/src/installs/keyboard-maestro.md +0 -825
  125. package/src/installs/kiro.js +0 -864
  126. package/src/installs/kiro.md +0 -1015
  127. package/src/installs/latex.js +0 -789
  128. package/src/installs/latex.md +0 -1095
  129. package/src/installs/lftp.js +0 -356
  130. package/src/installs/lftp.md +0 -907
  131. package/src/installs/lsb-release.js +0 -346
  132. package/src/installs/lsb-release.md +0 -814
  133. package/src/installs/messenger.js +0 -847
  134. package/src/installs/messenger.md +0 -900
  135. package/src/installs/microsoft-office.js +0 -568
  136. package/src/installs/microsoft-office.md +0 -760
  137. package/src/installs/microsoft-teams.js +0 -801
  138. package/src/installs/microsoft-teams.md +0 -886
  139. package/src/installs/moom.js +0 -326
  140. package/src/installs/moom.md +0 -570
  141. package/src/installs/node.js +0 -904
  142. package/src/installs/node.md +0 -1153
  143. package/src/installs/nordpass.js +0 -716
  144. package/src/installs/nordpass.md +0 -921
  145. package/src/installs/nordvpn.js +0 -892
  146. package/src/installs/nordvpn.md +0 -1052
  147. package/src/installs/nvm.js +0 -995
  148. package/src/installs/nvm.md +0 -1057
  149. package/src/installs/ohmyzsh.js +0 -529
  150. package/src/installs/ohmyzsh.md +0 -1094
  151. package/src/installs/openssh.js +0 -804
  152. package/src/installs/openssh.md +0 -1056
  153. package/src/installs/pandoc.js +0 -662
  154. package/src/installs/pandoc.md +0 -1036
  155. package/src/installs/parallels-desktop.js +0 -431
  156. package/src/installs/parallels-desktop.md +0 -446
  157. package/src/installs/pinentry.js +0 -510
  158. package/src/installs/pinentry.md +0 -1142
  159. package/src/installs/pngyu.js +0 -869
  160. package/src/installs/pngyu.md +0 -896
  161. package/src/installs/postman.js +0 -799
  162. package/src/installs/postman.md +0 -940
  163. package/src/installs/procps.js +0 -425
  164. package/src/installs/procps.md +0 -851
  165. package/src/installs/safari-tech-preview.js +0 -374
  166. package/src/installs/safari-tech-preview.md +0 -533
  167. package/src/installs/sfnt2woff.js +0 -658
  168. package/src/installs/sfnt2woff.md +0 -795
  169. package/src/installs/shellcheck.js +0 -481
  170. package/src/installs/shellcheck.md +0 -1005
  171. package/src/installs/slack.js +0 -741
  172. package/src/installs/slack.md +0 -865
  173. package/src/installs/snagit.js +0 -585
  174. package/src/installs/snagit.md +0 -844
  175. package/src/installs/software-properties-common.js +0 -372
  176. package/src/installs/software-properties-common.md +0 -805
  177. package/src/installs/spotify.js +0 -877
  178. package/src/installs/spotify.md +0 -901
  179. package/src/installs/studio-3t.js +0 -823
  180. package/src/installs/studio-3t.md +0 -918
  181. package/src/installs/sublime-text.js +0 -804
  182. package/src/installs/sublime-text.md +0 -914
  183. package/src/installs/superwhisper.js +0 -706
  184. package/src/installs/superwhisper.md +0 -630
  185. package/src/installs/tailscale.js +0 -745
  186. package/src/installs/tailscale.md +0 -1100
  187. package/src/installs/tar.js +0 -389
  188. package/src/installs/tar.md +0 -946
  189. package/src/installs/termius.js +0 -798
  190. package/src/installs/termius.md +0 -844
  191. package/src/installs/terraform.js +0 -779
  192. package/src/installs/terraform.md +0 -899
  193. package/src/installs/tfenv.js +0 -778
  194. package/src/installs/tfenv.md +0 -1091
  195. package/src/installs/tidal.js +0 -771
  196. package/src/installs/tidal.md +0 -864
  197. package/src/installs/tmux.js +0 -346
  198. package/src/installs/tmux.md +0 -1030
  199. package/src/installs/tree.js +0 -411
  200. package/src/installs/tree.md +0 -833
  201. package/src/installs/unzip.js +0 -460
  202. package/src/installs/unzip.md +0 -879
  203. package/src/installs/vim.js +0 -421
  204. package/src/installs/vim.md +0 -1040
  205. package/src/installs/vlc.js +0 -821
  206. package/src/installs/vlc.md +0 -927
  207. package/src/installs/vscode.js +0 -843
  208. package/src/installs/vscode.md +0 -1002
  209. package/src/installs/wget.js +0 -420
  210. package/src/installs/wget.md +0 -791
  211. package/src/installs/whatsapp.js +0 -729
  212. package/src/installs/whatsapp.md +0 -854
  213. package/src/installs/winpty.js +0 -352
  214. package/src/installs/winpty.md +0 -620
  215. package/src/installs/woff2.js +0 -553
  216. package/src/installs/woff2.md +0 -977
  217. package/src/installs/wsl.js +0 -572
  218. package/src/installs/wsl.md +0 -699
  219. package/src/installs/xcode-clt.js +0 -520
  220. package/src/installs/xcode-clt.md +0 -351
  221. package/src/installs/xcode.js +0 -560
  222. package/src/installs/xcode.md +0 -573
  223. package/src/installs/yarn.js +0 -824
  224. package/src/installs/yarn.md +0 -1074
  225. package/src/installs/yq.js +0 -654
  226. package/src/installs/yq.md +0 -944
  227. package/src/installs/yt-dlp.js +0 -701
  228. package/src/installs/yt-dlp.md +0 -946
  229. package/src/installs/yum-utils.js +0 -297
  230. package/src/installs/yum-utils.md +0 -648
  231. package/src/installs/zoom.js +0 -759
  232. package/src/installs/zoom.md +0 -884
  233. package/src/installs/zsh.js +0 -455
  234. package/src/installs/zsh.md +0 -1008
  235. package/src/scripts/README.md +0 -617
  236. package/src/scripts/STATUS.md +0 -208
  237. package/src/scripts/afk.js +0 -411
  238. package/src/scripts/backup-all.js +0 -746
  239. package/src/scripts/backup-source.js +0 -727
  240. package/src/scripts/brewd.js +0 -389
  241. package/src/scripts/brewi.js +0 -520
  242. package/src/scripts/brewr.js +0 -527
  243. package/src/scripts/brews.js +0 -477
  244. package/src/scripts/brewu.js +0 -504
  245. package/src/scripts/c.js +0 -201
  246. package/src/scripts/ccurl.js +0 -341
  247. package/src/scripts/certbot-crontab-init.js +0 -504
  248. package/src/scripts/certbot-init.js +0 -657
  249. package/src/scripts/ch.js +0 -355
  250. package/src/scripts/claude-danger.js +0 -268
  251. package/src/scripts/clean-dev.js +0 -435
  252. package/src/scripts/clear-dns-cache.js +0 -541
  253. package/src/scripts/clone.js +0 -435
  254. package/src/scripts/code-all.js +0 -437
  255. package/src/scripts/count-files.js +0 -211
  256. package/src/scripts/count-folders.js +0 -211
  257. package/src/scripts/count.js +0 -264
  258. package/src/scripts/d.js +0 -219
  259. package/src/scripts/datauri.js +0 -389
  260. package/src/scripts/delete-files.js +0 -380
  261. package/src/scripts/docker-clean.js +0 -426
  262. package/src/scripts/dp.js +0 -442
  263. package/src/scripts/e.js +0 -390
  264. package/src/scripts/empty-trash.js +0 -513
  265. package/src/scripts/evm.js +0 -444
  266. package/src/scripts/fetch-github-repos.js +0 -456
  267. package/src/scripts/get-channel.js +0 -345
  268. package/src/scripts/get-course.js +0 -399
  269. package/src/scripts/get-dependencies.js +0 -306
  270. package/src/scripts/get-folder.js +0 -799
  271. package/src/scripts/get-tunes.js +0 -426
  272. package/src/scripts/get-video.js +0 -367
  273. package/src/scripts/git-backup.js +0 -577
  274. package/src/scripts/git-clone.js +0 -493
  275. package/src/scripts/git-pup.js +0 -319
  276. package/src/scripts/git-push.js +0 -396
  277. package/src/scripts/h.js +0 -622
  278. package/src/scripts/hide-desktop-icons.js +0 -499
  279. package/src/scripts/hide-hidden-files.js +0 -538
  280. package/src/scripts/install-dependencies-from.js +0 -456
  281. package/src/scripts/ips.js +0 -663
  282. package/src/scripts/iso.js +0 -370
  283. package/src/scripts/killni.js +0 -577
  284. package/src/scripts/ll.js +0 -467
  285. package/src/scripts/local-ip.js +0 -325
  286. package/src/scripts/m.js +0 -524
  287. package/src/scripts/map.js +0 -309
  288. package/src/scripts/mkd.js +0 -351
  289. package/src/scripts/ncu-update-all.js +0 -457
  290. package/src/scripts/nginx-init.js +0 -718
  291. package/src/scripts/npmi.js +0 -382
  292. package/src/scripts/o.js +0 -511
  293. package/src/scripts/org-by-date.js +0 -338
  294. package/src/scripts/p.js +0 -224
  295. package/src/scripts/packages.js +0 -330
  296. package/src/scripts/path.js +0 -225
  297. package/src/scripts/ports.js +0 -597
  298. package/src/scripts/q.js +0 -305
  299. package/src/scripts/refresh-files.js +0 -394
  300. package/src/scripts/remove-smaller-files.js +0 -516
  301. package/src/scripts/rename-files-with-date.js +0 -533
  302. package/src/scripts/resize-image.js +0 -539
  303. package/src/scripts/rm-safe.js +0 -669
  304. package/src/scripts/s.js +0 -540
  305. package/src/scripts/set-git-public.js +0 -365
  306. package/src/scripts/show-desktop-icons.js +0 -475
  307. package/src/scripts/show-hidden-files.js +0 -472
  308. package/src/scripts/tpa.js +0 -280
  309. package/src/scripts/tpo.js +0 -280
  310. package/src/scripts/u.js +0 -505
  311. package/src/scripts/vpush.js +0 -437
  312. package/src/scripts/y.js +0 -283
  313. package/src/utils/README.md +0 -95
  314. package/src/utils/common/apps.js +0 -143
  315. package/src/utils/common/display.js +0 -157
  316. package/src/utils/common/network.js +0 -185
  317. package/src/utils/common/os.js +0 -294
  318. package/src/utils/common/package-manager.js +0 -301
  319. package/src/utils/common/privileges.js +0 -138
  320. package/src/utils/common/shell.js +0 -261
  321. package/src/utils/macos/apps.js +0 -228
  322. package/src/utils/macos/brew.js +0 -315
  323. package/src/utils/ubuntu/apt.js +0 -307
  324. package/src/utils/ubuntu/desktop.js +0 -292
  325. package/src/utils/ubuntu/snap.js +0 -344
  326. package/src/utils/ubuntu/systemd.js +0 -286
  327. package/src/utils/windows/choco.js +0 -465
  328. package/src/utils/windows/env.js +0 -246
  329. package/src/utils/windows/registry.js +0 -269
  330. package/src/utils/windows/shell.js +0 -240
  331. package/src/utils/windows/winget.js +0 -489
@@ -1,1630 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * @fileoverview Identity command - Manage identity profiles for git configuration,
5
- * SSH keys, and GPG signing.
6
- *
7
- * @depends-on SSH key generation:
8
- * - macOS: ssh-keygen (built-in, part of OpenSSH)
9
- * - Ubuntu/Debian: ssh-keygen (openssh-client package)
10
- * - Raspberry Pi: ssh-keygen (openssh-client package)
11
- * - Amazon Linux: ssh-keygen (openssh-clients package)
12
- * - RHEL/Fedora: ssh-keygen (openssh-clients package)
13
- * - Windows: ssh-keygen (OpenSSH via Windows Features, or Git Bash)
14
- * - WSL: ssh-keygen (openssh-client package)
15
- *
16
- * @depends-on GPG key generation:
17
- * - macOS: gpg (gnupg via Homebrew), pinentry-mac (for GUI passphrase prompts)
18
- * - Ubuntu/Debian: gpg (gnupg package)
19
- * - Raspberry Pi: gpg (gnupg package)
20
- * - Amazon Linux: gpg (gnupg2 package)
21
- * - RHEL/Fedora: gpg (gnupg2 package)
22
- * - Windows: gpg (Gpg4win via Chocolatey, or gnupg via winget)
23
- * - WSL: gpg (gnupg package)
24
- */
25
-
26
- const { Command } = require('commander');
27
- const fs = require('fs');
28
- const path = require('path');
29
- const readline = require('readline');
30
- const { execSync, spawn } = require('child_process');
31
-
32
- const shell = require('../utils/common/shell');
33
- const osUtils = require('../utils/common/os');
34
- const opensshInstall = require('../installs/openssh');
35
- const gpgInstall = require('../installs/gpg');
36
-
37
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE;
38
- const CONFIG_FILE = path.join(HOME_DIR, '.devutils');
39
- const SSH_DIR = path.join(HOME_DIR, '.ssh');
40
- const SSH_CONFIG_FILE = path.join(SSH_DIR, 'config');
41
- const GITCONFIG_FILE = path.join(HOME_DIR, '.gitconfig');
42
-
43
- // ============================================================================
44
- // URL and Argument Parsing Utilities
45
- // ============================================================================
46
-
47
- /**
48
- * Parse a remote URL and extract components
49
- * Supports: https://github.com/org, https://gitlab.com:8443/org,
50
- * git@github.com:org, ssh://git@gitlab.com:2222/org
51
- * @param {string} url - Remote URL to parse
52
- * @returns {{ host: string, port: number|null, pathPrefix: string, isSSH: boolean }|null}
53
- */
54
- function parseRemoteUrl(url) {
55
- if (!url) return null;
56
-
57
- // SSH format: git@github.com:org/repo or git@github.com:org
58
- const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
59
- if (sshMatch) {
60
- return {
61
- host: sshMatch[1],
62
- port: null,
63
- pathPrefix: sshMatch[2].replace(/\/$/, ''),
64
- isSSH: true
65
- };
66
- }
67
-
68
- // ssh:// format: ssh://git@github.com:2222/org or ssh://git@github.com/org
69
- const sshSchemeMatch = url.match(/^ssh:\/\/git@([^/:]+)(?::(\d+))?\/(.+?)(?:\.git)?$/);
70
- if (sshSchemeMatch) {
71
- return {
72
- host: sshSchemeMatch[1],
73
- port: sshSchemeMatch[2] ? parseInt(sshSchemeMatch[2], 10) : null,
74
- pathPrefix: sshSchemeMatch[3].replace(/\/$/, ''),
75
- isSSH: true
76
- };
77
- }
78
-
79
- // HTTPS format: https://github.com/org or https://gitlab.com:8443/org
80
- const httpsMatch = url.match(/^https?:\/\/([^/:]+)(?::(\d+))?(\/[^?#]*)?/);
81
- if (httpsMatch) {
82
- let pathPrefix = (httpsMatch[3] || '/').replace(/^\//, '').replace(/\/$/, '');
83
- // Remove .git suffix if present
84
- pathPrefix = pathPrefix.replace(/\.git$/, '');
85
- return {
86
- host: httpsMatch[1],
87
- port: httpsMatch[2] ? parseInt(httpsMatch[2], 10) : null,
88
- pathPrefix: pathPrefix,
89
- isSSH: false
90
- };
91
- }
92
-
93
- return null;
94
- }
95
-
96
- /**
97
- * Check if a string is a remote URL
98
- * @param {string} str - String to check
99
- * @returns {boolean}
100
- */
101
- function isRemoteUrl(str) {
102
- if (!str) return false;
103
- return /^(https?:\/\/|ssh:\/\/|git@)/.test(str);
104
- }
105
-
106
- /**
107
- * Check if a string is a folder path
108
- * @param {string} str - String to check
109
- * @returns {boolean}
110
- */
111
- function isFolderPath(str) {
112
- if (!str) return false;
113
- // Starts with /, ~/, ./, ../, or Windows drive letter (C:\)
114
- return /^(\/|~\/|\.\/|\.\.\/|[a-zA-Z]:\\)/.test(str);
115
- }
116
-
117
- /**
118
- * Expand ~ to home directory in path
119
- * @param {string} p - Path to expand
120
- * @returns {string}
121
- */
122
- function expandPath(p) {
123
- if (!p) return p;
124
- if (p.startsWith('~/')) {
125
- return path.join(HOME_DIR, p.slice(2));
126
- }
127
- return path.resolve(p);
128
- }
129
-
130
- /**
131
- * Contract home directory to ~ in path for display/storage
132
- * @param {string} p - Path to contract
133
- * @returns {string}
134
- */
135
- function contractPath(p) {
136
- if (!p) return p;
137
- if (p.startsWith(HOME_DIR)) {
138
- return '~' + p.slice(HOME_DIR.length);
139
- }
140
- return p;
141
- }
142
-
143
- /**
144
- * Parse command arguments and detect their types
145
- * @param {string[]} args - Array of arguments
146
- * @param {object} config - Config object with identities
147
- * @returns {{ identity: string|null, folderPath: string|null, remote: string|null }}
148
- */
149
- function parseArguments(args, config) {
150
- const result = { identity: null, folderPath: null, remote: null };
151
- const identityNames = Object.keys(config.identities || {});
152
-
153
- for (const arg of args) {
154
- if (!arg) continue;
155
-
156
- // Check if it's an identity name
157
- if (identityNames.includes(arg)) {
158
- result.identity = arg;
159
- continue;
160
- }
161
-
162
- // Check if it's a remote URL
163
- if (isRemoteUrl(arg)) {
164
- result.remote = arg;
165
- continue;
166
- }
167
-
168
- // Check if it's a folder path
169
- if (isFolderPath(arg)) {
170
- result.folderPath = arg;
171
- continue;
172
- }
173
-
174
- // Unknown argument - might be identity name that doesn't exist yet
175
- // but we'll treat unrecognized as potential identity for error messaging
176
- if (!result.identity) {
177
- result.identity = arg;
178
- }
179
- }
180
-
181
- return result;
182
- }
183
-
184
- /**
185
- * Generate SSH host alias name for an identity
186
- * @param {string} host - Git host (e.g., github.com)
187
- * @param {string} identityAlias - Identity alias
188
- * @returns {string}
189
- */
190
- function generateHostAlias(host, identityAlias) {
191
- return `${host}-${identityAlias}`;
192
- }
193
-
194
- // ============================================================================
195
- // File Generation Utilities
196
- // ============================================================================
197
-
198
- /**
199
- * Read SSH config file
200
- * @returns {string}
201
- */
202
- function readSshConfig() {
203
- if (fs.existsSync(SSH_CONFIG_FILE)) {
204
- return fs.readFileSync(SSH_CONFIG_FILE, 'utf8');
205
- }
206
- return '';
207
- }
208
-
209
- /**
210
- * Write SSH config file
211
- * @param {string} content - Config content
212
- */
213
- function writeSshConfig(content) {
214
- // Ensure .ssh directory exists
215
- if (!fs.existsSync(SSH_DIR)) {
216
- fs.mkdirSync(SSH_DIR, { mode: 0o700, recursive: true });
217
- }
218
- fs.writeFileSync(SSH_CONFIG_FILE, content, { mode: 0o600 });
219
- }
220
-
221
- /**
222
- * Update SSH config with a host entry (idempotent)
223
- * @param {string} hostAlias - Host alias (e.g., github.com-work)
224
- * @param {string} hostName - Actual hostname (e.g., github.com)
225
- * @param {string} identityFile - Path to SSH key
226
- * @param {number|null} port - Optional port number
227
- * @param {string} identityAlias - Identity name for comment
228
- */
229
- function updateSshConfig(hostAlias, hostName, identityFile, port, identityAlias) {
230
- let config = readSshConfig();
231
- const marker = `# Added by dev identity link (${identityAlias})`;
232
-
233
- // Build host entry
234
- let hostEntry = `${marker}\nHost ${hostAlias}\n`;
235
- hostEntry += ` HostName ${hostName}\n`;
236
- if (port) {
237
- hostEntry += ` Port ${port}\n`;
238
- }
239
- hostEntry += ` User git\n`;
240
- hostEntry += ` IdentityFile ${identityFile}\n`;
241
- hostEntry += ` IdentitiesOnly yes\n`;
242
-
243
- // Check if host entry already exists
244
- const hostPattern = new RegExp(
245
- `(${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\n)?Host ${hostAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\n[\\s\\S]*?(?=\n(?:Host |#|$))`,
246
- 'g'
247
- );
248
-
249
- if (config.match(hostPattern)) {
250
- // Replace existing entry
251
- config = config.replace(hostPattern, hostEntry.trim());
252
- } else {
253
- // Add new entry at the end
254
- config = config.trim() + '\n\n' + hostEntry;
255
- }
256
-
257
- writeSshConfig(config.trim() + '\n');
258
- }
259
-
260
- /**
261
- * Read gitconfig file
262
- * @param {string} filePath - Path to gitconfig
263
- * @returns {string}
264
- */
265
- function readGitconfig(filePath) {
266
- if (fs.existsSync(filePath)) {
267
- return fs.readFileSync(filePath, 'utf8');
268
- }
269
- return '';
270
- }
271
-
272
- /**
273
- * Write gitconfig file
274
- * @param {string} filePath - Path to gitconfig
275
- * @param {string} content - Config content
276
- */
277
- function writeGitconfig(filePath, content) {
278
- fs.writeFileSync(filePath, content);
279
- }
280
-
281
- /**
282
- * Generate profile-specific gitconfig content
283
- * @param {object} identity - Identity object
284
- * @param {string} identityAlias - Identity alias
285
- * @param {object[]} urlRewrites - Array of { hostAlias, pathPrefix, host, port }
286
- * @returns {string}
287
- */
288
- function generateProfileGitconfig(identity, identityAlias, urlRewrites) {
289
- let content = '# Generated by dev identity link\n';
290
- content += '[user]\n';
291
- content += ` email = ${identity.email}\n`;
292
-
293
- // Use SSH public key for signing if available
294
- const signingKey = identity.gpgKey || (identity.ssh ? identity.ssh.publicKey : null);
295
- if (signingKey) {
296
- content += ` signingkey = ${signingKey}\n`;
297
- }
298
-
299
- content += '\n[core]\n';
300
- const sshKeyPath = identity.sshKey || (identity.ssh ? identity.ssh.privateKey : null);
301
- if (sshKeyPath) {
302
- content += ` sshCommand = ssh -i ${sshKeyPath} -o IdentitiesOnly=yes\n`;
303
- }
304
-
305
- // Add URL rewrites
306
- for (const rewrite of urlRewrites) {
307
- content += '\n# URL rewriting (routes both HTTPS and SSH URLs through the host alias)\n';
308
- content += `[url "git@${rewrite.hostAlias}:${rewrite.pathPrefix}/"]\n`;
309
-
310
- // Generate HTTPS insteadOf
311
- const httpsPort = rewrite.port ? `:${rewrite.port}` : '';
312
- content += ` insteadOf = https://${rewrite.host}${httpsPort}/${rewrite.pathPrefix}/\n`;
313
-
314
- // Generate SSH insteadOf (git@ format)
315
- content += ` insteadOf = git@${rewrite.host}:${rewrite.pathPrefix}/\n`;
316
-
317
- // Generate SSH insteadOf (ssh:// format with port if applicable)
318
- if (rewrite.port) {
319
- content += ` insteadOf = ssh://git@${rewrite.host}:${rewrite.port}/${rewrite.pathPrefix}/\n`;
320
- } else {
321
- content += ` insteadOf = ssh://git@${rewrite.host}/${rewrite.pathPrefix}/\n`;
322
- }
323
- }
324
-
325
- // Add commit signing settings
326
- if (signingKey) {
327
- content += '\n[commit]\n';
328
- content += ' gpgsign = true\n';
329
- content += '\n[gpg]\n';
330
- content += ' format = ssh\n';
331
- }
332
-
333
- return content;
334
- }
335
-
336
- /**
337
- * Update main ~/.gitconfig with includeIf rules (idempotent)
338
- * @param {string} identityAlias - Identity alias
339
- * @param {string|null} folderPath - Folder path (with ~/)
340
- * @param {string|null} remote - Remote URL for hasconfig matching
341
- */
342
- function updateMainGitconfig(identityAlias, folderPath, remote) {
343
- let config = readGitconfig(GITCONFIG_FILE);
344
- const profilePath = `~/.gitconfig-${identityAlias}`;
345
- const marker = `# ${identityAlias} identity (added by dev identity link)`;
346
-
347
- // Build includeIf sections
348
- let includeSection = '';
349
-
350
- if (folderPath) {
351
- // Ensure trailing slash for gitdir
352
- const gitdirPath = folderPath.endsWith('/') ? folderPath : folderPath + '/';
353
- includeSection += `${marker}\n`;
354
- includeSection += `[includeIf "gitdir:${gitdirPath}"]\n`;
355
- includeSection += ` path = ${profilePath}\n`;
356
- }
357
-
358
- if (remote) {
359
- const parsed = parseRemoteUrl(remote);
360
- if (parsed) {
361
- // Add hasconfig rule for git@ format
362
- if (includeSection) includeSection += '\n';
363
- else includeSection += `${marker}\n`;
364
- includeSection += `[includeIf "hasconfig:remote.*.url:git@${parsed.host}:${parsed.pathPrefix}/**"]\n`;
365
- includeSection += ` path = ${profilePath}\n`;
366
- }
367
- }
368
-
369
- if (!includeSection) return;
370
-
371
- // Check if marker already exists
372
- const markerRegex = new RegExp(
373
- `${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\n[\\s\\S]*?(?=\n# \\w+ identity \\(added by|$)`,
374
- 'g'
375
- );
376
-
377
- if (config.match(markerRegex)) {
378
- // Replace existing section
379
- config = config.replace(markerRegex, includeSection.trim());
380
- } else {
381
- // Add at end
382
- config = config.trim() + '\n\n' + includeSection;
383
- }
384
-
385
- writeGitconfig(GITCONFIG_FILE, config.trim() + '\n');
386
- }
387
-
388
- // ============================================================================
389
- // Validation Utilities
390
- // ============================================================================
391
-
392
- /**
393
- * Check if two paths overlap (one is parent/child of the other)
394
- * @param {string} path1 - First path
395
- * @param {string} path2 - Second path
396
- * @returns {boolean}
397
- */
398
- function pathsOverlap(path1, path2) {
399
- const expanded1 = expandPath(path1);
400
- const expanded2 = expandPath(path2);
401
-
402
- // Same path
403
- if (expanded1 === expanded2) return true;
404
-
405
- // One is parent of the other
406
- const rel1 = path.relative(expanded1, expanded2);
407
- const rel2 = path.relative(expanded2, expanded1);
408
-
409
- // If relative path doesn't start with .., one is inside the other
410
- return (!rel1.startsWith('..') && rel1 !== '') ||
411
- (!rel2.startsWith('..') && rel2 !== '');
412
- }
413
-
414
- /**
415
- * Get all linked paths from config
416
- * @param {object} config - Configuration object
417
- * @returns {Array<{ path: string, identity: string }>}
418
- */
419
- function getAllLinkedPaths(config) {
420
- const result = [];
421
- for (const [name, identity] of Object.entries(config.identities || {})) {
422
- for (const link of (identity.links || [])) {
423
- if (link.path) {
424
- result.push({ path: link.path, identity: name });
425
- }
426
- }
427
- }
428
- return result;
429
- }
430
-
431
- /**
432
- * Check if a path would conflict with existing links
433
- * @param {string} folderPath - Path to check
434
- * @param {string} identityAlias - Identity being linked
435
- * @param {object} config - Configuration object
436
- * @returns {{ conflict: boolean, message: string|null }}
437
- */
438
- function checkPathConflict(folderPath, identityAlias, config) {
439
- const linkedPaths = getAllLinkedPaths(config);
440
-
441
- for (const linked of linkedPaths) {
442
- if (pathsOverlap(folderPath, linked.path)) {
443
- if (linked.identity === identityAlias && expandPath(folderPath) === expandPath(linked.path)) {
444
- // Same identity, same path - not a conflict, just idempotent
445
- return { conflict: false, message: null };
446
- }
447
-
448
- const expanded = expandPath(folderPath);
449
- const linkedExpanded = expandPath(linked.path);
450
-
451
- if (expanded === linkedExpanded) {
452
- return {
453
- conflict: true,
454
- message: `Path ${contractPath(folderPath)} is already linked to identity "${linked.identity}"`
455
- };
456
- }
457
-
458
- const rel = path.relative(expanded, linkedExpanded);
459
- if (!rel.startsWith('..')) {
460
- return {
461
- conflict: true,
462
- message: `Cannot link ${contractPath(folderPath)} - child path ${contractPath(linked.path)} is already linked to identity "${linked.identity}"`
463
- };
464
- }
465
-
466
- return {
467
- conflict: true,
468
- message: `Cannot link ${contractPath(folderPath)} - parent path ${contractPath(linked.path)} is already linked to identity "${linked.identity}"`
469
- };
470
- }
471
- }
472
-
473
- return { conflict: false, message: null };
474
- }
475
-
476
- /**
477
- * Find a link by folder path
478
- * @param {string} folderPath - Path to find
479
- * @param {object} config - Configuration object
480
- * @returns {{ identity: string, link: object, linkIndex: number }|null}
481
- */
482
- function findLinkByPath(folderPath, config) {
483
- const expandedPath = expandPath(folderPath);
484
-
485
- for (const [identityName, identity] of Object.entries(config.identities || {})) {
486
- const links = identity.links || [];
487
- for (let i = 0; i < links.length; i++) {
488
- if (links[i].path && expandPath(links[i].path) === expandedPath) {
489
- return {
490
- identity: identityName,
491
- link: links[i],
492
- linkIndex: i
493
- };
494
- }
495
- }
496
- }
497
-
498
- return null;
499
- }
500
-
501
- /**
502
- * Remove gitdir includeIf rule from main ~/.gitconfig (idempotent)
503
- * @param {string} folderPath - Folder path to remove (with ~/)
504
- */
505
- function removeGitdirIncludeIf(folderPath) {
506
- let config = readGitconfig(GITCONFIG_FILE);
507
- if (!config) return;
508
-
509
- // Ensure trailing slash for matching
510
- const gitdirPath = folderPath.endsWith('/') ? folderPath : folderPath + '/';
511
-
512
- // Match the includeIf gitdir block
513
- // Pattern: [includeIf "gitdir:~/path/"]\n path = ~/.gitconfig-xxx\n
514
- const pattern = new RegExp(
515
- `\\[includeIf "gitdir:${gitdirPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\]\\n\\s+path = [^\\n]+\\n?`,
516
- 'g'
517
- );
518
-
519
- const newConfig = config.replace(pattern, '');
520
-
521
- // Clean up any double newlines that might result
522
- const cleaned = newConfig.replace(/\n{3,}/g, '\n\n').trim();
523
-
524
- writeGitconfig(GITCONFIG_FILE, cleaned + '\n');
525
- }
526
-
527
- /**
528
- * Check if SSH tools are available
529
- * @returns {boolean}
530
- */
531
- function isSshAvailable() {
532
- return shell.commandExists('ssh-keygen');
533
- }
534
-
535
- /**
536
- * Check if GPG tools are available
537
- * @returns {boolean}
538
- */
539
- function isGpgAvailable() {
540
- return shell.commandExists('gpg');
541
- }
542
-
543
- /**
544
- * Get platform-specific package info for display
545
- * @param {string} tool - 'ssh' or 'gpg'
546
- * @returns {{ command: string, package: string }}
547
- */
548
- function getPackageInfo(tool) {
549
- const platform = osUtils.detect();
550
-
551
- if (tool === 'ssh') {
552
- switch (platform.type) {
553
- case 'macos':
554
- return { command: 'ssh-keygen', package: 'built-in (OpenSSH)' };
555
- case 'ubuntu':
556
- case 'debian':
557
- case 'raspbian':
558
- case 'wsl':
559
- return { command: 'ssh-keygen', package: 'openssh-client (apt)' };
560
- case 'amazon_linux':
561
- case 'fedora':
562
- case 'rhel':
563
- return { command: 'ssh-keygen', package: `openssh-clients (${platform.packageManager})` };
564
- case 'windows':
565
- return { command: 'ssh-keygen', package: 'OpenSSH (winget) or Git Bash' };
566
- default:
567
- return { command: 'ssh-keygen', package: 'openssh' };
568
- }
569
- } else if (tool === 'gpg') {
570
- switch (platform.type) {
571
- case 'macos':
572
- return { command: 'gpg', package: 'gnupg (brew)' };
573
- case 'ubuntu':
574
- case 'debian':
575
- case 'raspbian':
576
- case 'wsl':
577
- return { command: 'gpg', package: 'gnupg (apt)' };
578
- case 'amazon_linux':
579
- case 'fedora':
580
- case 'rhel':
581
- return { command: 'gpg', package: `gnupg2 (${platform.packageManager})` };
582
- case 'windows':
583
- return { command: 'gpg', package: 'Gpg4win (winget/choco)' };
584
- default:
585
- return { command: 'gpg', package: 'gnupg' };
586
- }
587
- }
588
- return { command: tool, package: 'unknown' };
589
- }
590
-
591
- /**
592
- * Check and optionally install SSH dependency
593
- * @param {readline.Interface} rl - Readline interface
594
- * @param {boolean} force - Skip prompts if true
595
- * @returns {Promise<boolean>} - True if SSH is available after check/install
596
- */
597
- async function ensureSshAvailable(rl, force) {
598
- if (isSshAvailable()) {
599
- return true;
600
- }
601
-
602
- const pkgInfo = getPackageInfo('ssh');
603
- console.log(`\nWarning: ${pkgInfo.command} is not installed.`);
604
- console.log(`Package: ${pkgInfo.package}`);
605
-
606
- let shouldInstall = force;
607
- if (!force) {
608
- shouldInstall = await confirm(rl, 'Would you like to install it now?');
609
- }
610
-
611
- if (!shouldInstall) {
612
- console.log('SSH key generation requires ssh-keygen. Skipping SSH key generation.');
613
- return false;
614
- }
615
-
616
- console.log('\nInstalling OpenSSH...');
617
- const success = await opensshInstall.install();
618
-
619
- if (!success) {
620
- console.error('Failed to install OpenSSH.');
621
- return false;
622
- }
623
-
624
- // Verify installation
625
- if (!isSshAvailable()) {
626
- console.error('OpenSSH was installed but ssh-keygen is still not available.');
627
- console.log('You may need to restart your terminal or add it to PATH.');
628
- return false;
629
- }
630
-
631
- console.log('OpenSSH installed and verified.\n');
632
- return true;
633
- }
634
-
635
- /**
636
- * Check and optionally install GPG dependency
637
- * @param {readline.Interface} rl - Readline interface
638
- * @param {boolean} force - Skip prompts if true
639
- * @returns {Promise<boolean>} - True if GPG is available after check/install
640
- */
641
- async function ensureGpgAvailable(rl, force) {
642
- if (isGpgAvailable()) {
643
- return true;
644
- }
645
-
646
- const pkgInfo = getPackageInfo('gpg');
647
- console.log(`\nWarning: ${pkgInfo.command} is not installed.`);
648
- console.log(`Package: ${pkgInfo.package}`);
649
-
650
- let shouldInstall = force;
651
- if (!force) {
652
- shouldInstall = await confirm(rl, 'Would you like to install it now?');
653
- }
654
-
655
- if (!shouldInstall) {
656
- console.log('GPG key generation requires gpg. Skipping GPG key generation.');
657
- return false;
658
- }
659
-
660
- console.log('\nInstalling GPG...');
661
- const success = await gpgInstall.install();
662
-
663
- if (!success) {
664
- console.error('Failed to install GPG.');
665
- return false;
666
- }
667
-
668
- // Verify installation
669
- if (!isGpgAvailable()) {
670
- console.error('GPG was installed but gpg is still not available.');
671
- console.log('You may need to restart your terminal or add it to PATH.');
672
- return false;
673
- }
674
-
675
- console.log('GPG installed and verified.\n');
676
- return true;
677
- }
678
-
679
- /**
680
- * Create readline interface for prompts
681
- * @returns {readline.Interface}
682
- */
683
- function createPrompt() {
684
- return readline.createInterface({
685
- input: process.stdin,
686
- output: process.stdout
687
- });
688
- }
689
-
690
- /**
691
- * Prompt user for input
692
- * @param {readline.Interface} rl - Readline interface
693
- * @param {string} question - Question to ask
694
- * @param {string} [defaultValue] - Default value if empty
695
- * @returns {Promise<string>}
696
- */
697
- function ask(rl, question, defaultValue) {
698
- return new Promise(resolve => {
699
- const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
700
- rl.question(prompt, answer => {
701
- resolve(answer.trim() || defaultValue || '');
702
- });
703
- });
704
- }
705
-
706
- /**
707
- * Prompt user for confirmation
708
- * @param {readline.Interface} rl - Readline interface
709
- * @param {string} question - Question to ask
710
- * @returns {Promise<boolean>}
711
- */
712
- function confirm(rl, question) {
713
- return new Promise(resolve => {
714
- rl.question(`${question} (y/n): `, answer => {
715
- resolve(answer.toLowerCase().startsWith('y'));
716
- });
717
- });
718
- }
719
-
720
- /**
721
- * Load existing configuration
722
- * @returns {object}
723
- */
724
- function loadConfig() {
725
- try {
726
- if (fs.existsSync(CONFIG_FILE)) {
727
- const content = fs.readFileSync(CONFIG_FILE, 'utf8');
728
- return JSON.parse(content);
729
- }
730
- } catch {
731
- // Return empty config on error
732
- }
733
- return {};
734
- }
735
-
736
- /**
737
- * Save configuration to file
738
- * @param {object} config - Configuration object
739
- */
740
- function saveConfig(config) {
741
- config.updated = new Date().toISOString();
742
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
743
- }
744
-
745
- /**
746
- * Generate SSH key pair
747
- * @param {string} alias - Identity alias
748
- * @param {string} email - Email address
749
- * @param {string} passphrase - Optional passphrase
750
- * @param {string} keyType - Key type (ed25519 or rsa)
751
- * @returns {Promise<{ privateKey: string, publicKey: string }>}
752
- */
753
- async function generateSSHKey(alias, email, passphrase, keyType = 'ed25519') {
754
- // Ensure .ssh directory exists
755
- if (!fs.existsSync(SSH_DIR)) {
756
- fs.mkdirSync(SSH_DIR, { mode: 0o700 });
757
- }
758
-
759
- const keyFile = path.join(SSH_DIR, `id_${keyType}_${alias}`);
760
-
761
- // Check if key already exists
762
- if (fs.existsSync(keyFile)) {
763
- throw new Error(`SSH key already exists at ${keyFile}`);
764
- }
765
-
766
- return new Promise((resolve, reject) => {
767
- const args = [
768
- '-t', keyType,
769
- '-C', email,
770
- '-f', keyFile,
771
- '-N', passphrase || ''
772
- ];
773
-
774
- if (keyType === 'rsa') {
775
- args.push('-b', '4096');
776
- }
777
-
778
- const sshKeygen = spawn('ssh-keygen', args, { stdio: 'inherit' });
779
-
780
- sshKeygen.on('close', code => {
781
- if (code === 0) {
782
- resolve({
783
- privateKey: keyFile,
784
- publicKey: `${keyFile}.pub`
785
- });
786
- } else {
787
- reject(new Error(`ssh-keygen exited with code ${code}`));
788
- }
789
- });
790
-
791
- sshKeygen.on('error', err => {
792
- reject(new Error(`Failed to run ssh-keygen: ${err.message}`));
793
- });
794
- });
795
- }
796
-
797
- /**
798
- * Generate GPG key pair
799
- * @param {string} name - User's name
800
- * @param {string} email - Email address
801
- * @returns {Promise<{ keyId: string, fingerprint: string }>}
802
- */
803
- async function generateGPGKey(name, email) {
804
- return new Promise((resolve, reject) => {
805
- // Create batch file content for GPG key generation
806
- const batchContent = `
807
- %no-protection
808
- Key-Type: eddsa
809
- Key-Curve: ed25519
810
- Key-Usage: sign
811
- Subkey-Type: ecdh
812
- Subkey-Curve: cv25519
813
- Subkey-Usage: encrypt
814
- Name-Real: ${name}
815
- Name-Email: ${email}
816
- Expire-Date: 0
817
- %commit
818
- `.trim();
819
-
820
- // Write batch file to temp location
821
- const tempBatchFile = path.join(require('os').tmpdir(), `gpg-batch-${Date.now()}`);
822
- fs.writeFileSync(tempBatchFile, batchContent);
823
-
824
- const gpg = spawn('gpg', ['--batch', '--gen-key', tempBatchFile], {
825
- stdio: ['pipe', 'pipe', 'pipe']
826
- });
827
-
828
- let stderr = '';
829
-
830
- gpg.stderr.on('data', (data) => {
831
- stderr += data.toString();
832
- });
833
-
834
- gpg.on('close', async (code) => {
835
- // Clean up temp file
836
- try {
837
- fs.unlinkSync(tempBatchFile);
838
- } catch {
839
- // Ignore cleanup errors
840
- }
841
-
842
- if (code !== 0) {
843
- reject(new Error(`gpg exited with code ${code}: ${stderr}`));
844
- return;
845
- }
846
-
847
- // Get the key ID for the newly created key
848
- try {
849
- const keyId = await getGPGKeyId(email);
850
- if (keyId) {
851
- resolve(keyId);
852
- } else {
853
- reject(new Error('GPG key created but could not retrieve key ID'));
854
- }
855
- } catch (err) {
856
- reject(err);
857
- }
858
- });
859
-
860
- gpg.on('error', err => {
861
- try {
862
- fs.unlinkSync(tempBatchFile);
863
- } catch {
864
- // Ignore cleanup errors
865
- }
866
- reject(new Error(`Failed to run gpg: ${err.message}`));
867
- });
868
- });
869
- }
870
-
871
- /**
872
- * Get GPG key ID for an email address
873
- * @param {string} email - Email address to look up
874
- * @returns {Promise<{ keyId: string, fingerprint: string }|null>}
875
- */
876
- async function getGPGKeyId(email) {
877
- return new Promise((resolve, reject) => {
878
- const gpg = spawn('gpg', ['--list-keys', '--keyid-format', 'long', email], {
879
- stdio: ['pipe', 'pipe', 'pipe']
880
- });
881
-
882
- let stdout = '';
883
- let stderr = '';
884
-
885
- gpg.stdout.on('data', (data) => {
886
- stdout += data.toString();
887
- });
888
-
889
- gpg.stderr.on('data', (data) => {
890
- stderr += data.toString();
891
- });
892
-
893
- gpg.on('close', (code) => {
894
- if (code !== 0) {
895
- resolve(null);
896
- return;
897
- }
898
-
899
- // Parse the output to get key ID and fingerprint
900
- // Look for lines like: "pub ed25519/KEYID 2024-01-01 [SC]"
901
- const pubMatch = stdout.match(/pub\s+\w+\/([A-F0-9]+)\s/i);
902
- const fpMatch = stdout.match(/^\s+([A-F0-9]{40})\s*$/m);
903
-
904
- if (pubMatch) {
905
- resolve({
906
- keyId: pubMatch[1],
907
- fingerprint: fpMatch ? fpMatch[1] : pubMatch[1]
908
- });
909
- } else {
910
- resolve(null);
911
- }
912
- });
913
-
914
- gpg.on('error', () => {
915
- resolve(null);
916
- });
917
- });
918
- }
919
-
920
- /**
921
- * List all configured identities
922
- * @param {object} config - Configuration object
923
- */
924
- function listIdentities(config) {
925
- const identities = config.identities || {};
926
- const names = Object.keys(identities);
927
-
928
- if (names.length === 0) {
929
- console.log('\nNo identities configured.');
930
- console.log('Run `dev identity add <alias>` to create one.\n');
931
- return;
932
- }
933
-
934
- console.log('\nConfigured identities:');
935
- console.log('─'.repeat(50));
936
-
937
- for (const name of names) {
938
- const identity = identities[name];
939
- console.log(`\n ${name}:`);
940
- console.log(` Name: ${identity.name}`);
941
- console.log(` Email: ${identity.email}`);
942
-
943
- // Show SSH key
944
- const sshKey = identity.sshKey || (identity.ssh ? identity.ssh.publicKey : null);
945
- if (sshKey) {
946
- console.log(` SSH: ${sshKey}`);
947
- }
948
-
949
- // Show GPG key
950
- const gpgKey = identity.gpgKey || (identity.gpg ? identity.gpg.keyId : null);
951
- if (gpgKey) {
952
- console.log(` GPG: ${gpgKey}`);
953
- }
954
-
955
- // Show links
956
- const links = identity.links || [];
957
- if (links.length > 0) {
958
- console.log(` Links:`);
959
- for (const link of links) {
960
- let linkDesc = ' - ';
961
- if (link.path && link.remote) {
962
- linkDesc += `${link.path} → ${link.remote}`;
963
- } else if (link.path) {
964
- linkDesc += `${link.path}`;
965
- } else if (link.remote) {
966
- linkDesc += `${link.remote}`;
967
- }
968
- console.log(linkDesc);
969
- }
970
- }
971
- }
972
- console.log('');
973
- }
974
-
975
- /**
976
- * Add a new identity
977
- * @param {string} alias - Identity alias
978
- * @param {object} options - Command options
979
- */
980
- async function addIdentity(alias, options) {
981
- if (!alias) {
982
- console.error('\nError: Alias is required.');
983
- console.log('Usage: dev identity add <alias> [--name <name>] [--email <email>]\n');
984
- process.exit(1);
985
- }
986
-
987
- const config = loadConfig();
988
- config.identities = config.identities || {};
989
-
990
- // Check if alias already exists
991
- if (config.identities[alias] && !options.force) {
992
- console.error(`\nError: Identity "${alias}" already exists.`);
993
- console.log('Use --force to overwrite.\n');
994
- process.exit(1);
995
- }
996
-
997
- // Get name and email from options or prompt
998
- let name = options.name;
999
- let email = options.email;
1000
-
1001
- // Defaults come from user info in .devutils
1002
- const defaultName = config.user?.name || '';
1003
- const defaultEmail = config.user?.email || '';
1004
-
1005
- // If both provided via CLI, skip interactive mode
1006
- const hasAllRequired = name && email;
1007
-
1008
- if (!hasAllRequired) {
1009
- const rl = createPrompt();
1010
-
1011
- console.log(`\n--- Add Identity: ${alias} ---\n`);
1012
-
1013
- if (!name) {
1014
- name = await ask(rl, 'Name', defaultName);
1015
- if (!name) {
1016
- console.error('\nError: Name is required.');
1017
- rl.close();
1018
- process.exit(1);
1019
- }
1020
- }
1021
-
1022
- if (!email) {
1023
- email = await ask(rl, 'Email', defaultEmail);
1024
- if (!email) {
1025
- console.error('\nError: Email is required.');
1026
- rl.close();
1027
- process.exit(1);
1028
- }
1029
- }
1030
-
1031
- rl.close();
1032
- }
1033
-
1034
- // Create identity object
1035
- const identity = {
1036
- name,
1037
- email,
1038
- created: new Date().toISOString()
1039
- };
1040
-
1041
- // SSH key generation (always create without passphrase)
1042
- console.log('\n--- SSH Key ---\n');
1043
- const rl = createPrompt();
1044
- const sshAvailable = await ensureSshAvailable(rl, options.force);
1045
- if (sshAvailable) {
1046
- const keyType = options.sshType || 'ed25519';
1047
-
1048
- try {
1049
- console.log(`Generating ${keyType} SSH key...`);
1050
- const sshKey = await generateSSHKey(alias, email, '', keyType);
1051
- identity.ssh = sshKey;
1052
- console.log(`SSH key created: ${sshKey.publicKey}`);
1053
-
1054
- // Show public key
1055
- const publicKey = fs.readFileSync(sshKey.publicKey, 'utf8');
1056
- console.log('\nPublic key:');
1057
- console.log('─'.repeat(40));
1058
- console.log(publicKey.trim());
1059
- console.log('─'.repeat(40));
1060
- } catch (err) {
1061
- console.error(`\nWarning: Failed to generate SSH key: ${err.message}`);
1062
- }
1063
- }
1064
-
1065
- // GPG key generation (always create)
1066
- console.log('\n--- GPG Key ---\n');
1067
- const gpgAvailable = await ensureGpgAvailable(rl, options.force);
1068
- if (gpgAvailable) {
1069
- try {
1070
- console.log('Generating GPG key...');
1071
- const gpgKey = await generateGPGKey(name, email);
1072
- identity.gpg = gpgKey;
1073
- console.log(`GPG key created: ${gpgKey.keyId}`);
1074
- console.log(`Fingerprint: ${gpgKey.fingerprint}`);
1075
- } catch (err) {
1076
- console.error(`\nWarning: Failed to generate GPG key: ${err.message}`);
1077
- }
1078
- }
1079
-
1080
- rl.close();
1081
-
1082
- // Save identity
1083
- config.identities[alias] = identity;
1084
- saveConfig(config);
1085
-
1086
- console.log(`\nIdentity "${alias}" saved.`);
1087
- console.log(`Configuration updated: ${CONFIG_FILE}\n`);
1088
- }
1089
-
1090
- /**
1091
- * Update an existing identity
1092
- * @param {string} alias - Identity alias
1093
- * @param {object} options - Command options
1094
- */
1095
- async function updateIdentity(alias, options) {
1096
- if (!alias) {
1097
- console.error('\nError: Alias is required.');
1098
- console.log('Usage: dev identity update <alias> [--name <name>] [--email <email>]\n');
1099
- process.exit(1);
1100
- }
1101
-
1102
- const config = loadConfig();
1103
- config.identities = config.identities || {};
1104
-
1105
- // Check if alias exists
1106
- if (!config.identities[alias]) {
1107
- console.error(`\nError: Identity "${alias}" not found.\n`);
1108
- process.exit(1);
1109
- }
1110
-
1111
- const existingIdentity = config.identities[alias];
1112
-
1113
- // Get name and email from options or prompt
1114
- let name = options.name;
1115
- let email = options.email;
1116
-
1117
- // Defaults come from existing identity
1118
- const defaultName = existingIdentity.name;
1119
- const defaultEmail = existingIdentity.email;
1120
-
1121
- // If both provided via CLI, skip interactive mode
1122
- const hasAllRequired = name && email;
1123
-
1124
- if (!hasAllRequired) {
1125
- const rl = createPrompt();
1126
-
1127
- console.log(`\n--- Update Identity: ${alias} ---\n`);
1128
-
1129
- if (!name) {
1130
- name = await ask(rl, 'Name', defaultName);
1131
- if (!name) {
1132
- console.error('\nError: Name is required.');
1133
- rl.close();
1134
- process.exit(1);
1135
- }
1136
- }
1137
-
1138
- if (!email) {
1139
- email = await ask(rl, 'Email', defaultEmail);
1140
- if (!email) {
1141
- console.error('\nError: Email is required.');
1142
- rl.close();
1143
- process.exit(1);
1144
- }
1145
- }
1146
-
1147
- rl.close();
1148
- }
1149
-
1150
- // Update identity object (preserve SSH/GPG keys)
1151
- config.identities[alias] = {
1152
- ...existingIdentity,
1153
- name,
1154
- email,
1155
- updated: new Date().toISOString()
1156
- };
1157
-
1158
- saveConfig(config);
1159
-
1160
- console.log(`\nIdentity "${alias}" updated.`);
1161
- console.log(`Configuration updated: ${CONFIG_FILE}\n`);
1162
- }
1163
-
1164
- /**
1165
- * Remove an identity
1166
- * @param {string} alias - Identity alias to remove
1167
- * @param {object} options - Command options
1168
- */
1169
- async function removeIdentity(alias, options) {
1170
- if (!alias) {
1171
- console.error('\nError: Alias is required.');
1172
- console.log('Usage: dev identity remove <alias>\n');
1173
- process.exit(1);
1174
- }
1175
-
1176
- const config = loadConfig();
1177
- config.identities = config.identities || {};
1178
-
1179
- // Check if identity exists
1180
- if (!config.identities[alias]) {
1181
- console.error(`\nError: Identity "${alias}" not found.\n`);
1182
- process.exit(1);
1183
- }
1184
-
1185
- // Confirm deletion (unless --force)
1186
- if (!options.force) {
1187
- const rl = createPrompt();
1188
- const shouldDelete = await confirm(rl, `Delete identity "${alias}"?`);
1189
- rl.close();
1190
-
1191
- if (!shouldDelete) {
1192
- console.log('Cancelled.');
1193
- return;
1194
- }
1195
- }
1196
-
1197
- // Delete SSH keys if they exist
1198
- const identity = config.identities[alias];
1199
- if (identity.ssh) {
1200
- if (fs.existsSync(identity.ssh.privateKey)) {
1201
- fs.unlinkSync(identity.ssh.privateKey);
1202
- console.log(`Deleted: ${identity.ssh.privateKey}`);
1203
- }
1204
- if (fs.existsSync(identity.ssh.publicKey)) {
1205
- fs.unlinkSync(identity.ssh.publicKey);
1206
- console.log(`Deleted: ${identity.ssh.publicKey}`);
1207
- }
1208
- }
1209
-
1210
- // Remove from config
1211
- delete config.identities[alias];
1212
- saveConfig(config);
1213
-
1214
- console.log(`\nIdentity "${alias}" removed.`);
1215
- console.log(`Configuration updated: ${CONFIG_FILE}\n`);
1216
- }
1217
-
1218
- /**
1219
- * Link an identity to a source folder and/or remote server
1220
- * @param {string[]} args - Command arguments (identity, path, remote in any order)
1221
- */
1222
- async function linkIdentity(args) {
1223
- const config = loadConfig();
1224
- config.identities = config.identities || {};
1225
-
1226
- const identityNames = Object.keys(config.identities);
1227
-
1228
- if (identityNames.length === 0) {
1229
- console.log('\nNo identities configured.');
1230
- console.log('Run `dev identity add <alias>` first.\n');
1231
- return;
1232
- }
1233
-
1234
- // Parse arguments to detect types
1235
- const parsed = parseArguments(args, config);
1236
- let { identity: alias, folderPath, remote } = parsed;
1237
-
1238
- const rl = createPrompt();
1239
-
1240
- // Prompt for identity if not provided
1241
- if (!alias) {
1242
- console.log('\nAvailable identities:');
1243
- identityNames.forEach((n, i) => {
1244
- console.log(` ${i + 1}. ${n} (${config.identities[n].email})`);
1245
- });
1246
- alias = await ask(rl, '\nSelect identity');
1247
- }
1248
-
1249
- // Validate identity exists
1250
- if (!config.identities[alias]) {
1251
- console.error(`\n✗ Error: Identity "${alias}" not found.`);
1252
- console.log(`Available identities: ${identityNames.join(', ')}\n`);
1253
- rl.close();
1254
- process.exit(1);
1255
- }
1256
-
1257
- // Only prompt for path/remote if NEITHER was provided
1258
- // If at least one was provided, use it without prompting
1259
- if (!folderPath && !remote) {
1260
- folderPath = await ask(rl, 'Source folder path (optional, press Enter to skip)');
1261
- if (!folderPath) {
1262
- remote = await ask(rl, 'Remote server URL (optional, press Enter to skip)');
1263
- }
1264
-
1265
- // Must have at least one of path or remote
1266
- if (!folderPath && !remote) {
1267
- console.error('\n✗ Error: You must provide at least a folder path or remote URL.\n');
1268
- rl.close();
1269
- process.exit(1);
1270
- }
1271
- }
1272
-
1273
- rl.close();
1274
-
1275
- const identity = config.identities[alias];
1276
-
1277
- // Validate and process folder path
1278
- if (folderPath) {
1279
- const expandedPath = expandPath(folderPath);
1280
-
1281
- // Check if path exists
1282
- if (!fs.existsSync(expandedPath)) {
1283
- const rl2 = createPrompt();
1284
- const create = await confirm(rl2, `Folder ${folderPath} does not exist. Create it?`);
1285
- rl2.close();
1286
-
1287
- if (create) {
1288
- try {
1289
- fs.mkdirSync(expandedPath, { recursive: true });
1290
- console.log(`✓ Created folder ${folderPath}`);
1291
- } catch (err) {
1292
- console.error(`\n✗ Error creating folder: ${err.message}\n`);
1293
- process.exit(1);
1294
- }
1295
- } else {
1296
- console.log('Aborted.');
1297
- return;
1298
- }
1299
- }
1300
-
1301
- // Check for path conflicts with existing links
1302
- const conflict = checkPathConflict(folderPath, alias, config);
1303
- if (conflict.conflict) {
1304
- console.error(`\n✗ Error: ${conflict.message}\n`);
1305
- process.exit(1);
1306
- }
1307
-
1308
- // Normalize to contracted path for storage
1309
- folderPath = contractPath(expandedPath);
1310
- }
1311
-
1312
- // Parse remote URL if provided
1313
- let parsedRemote = null;
1314
- if (remote) {
1315
- parsedRemote = parseRemoteUrl(remote);
1316
- if (!parsedRemote) {
1317
- console.error(`\n✗ Error: Could not parse remote URL: ${remote}\n`);
1318
- process.exit(1);
1319
- }
1320
- }
1321
-
1322
- // ================================================================
1323
- // Step 1: Store link in ~/.devutils (source of truth)
1324
- // ================================================================
1325
- identity.links = identity.links || [];
1326
-
1327
- // Check if this exact link already exists (idempotent)
1328
- const existingLinkIndex = identity.links.findIndex(link => {
1329
- if (folderPath && link.path !== folderPath) return false;
1330
- if (remote && link.remote !== remote) return false;
1331
- if (!folderPath && link.path) return false;
1332
- if (!remote && link.remote) return false;
1333
- return true;
1334
- });
1335
-
1336
- const linkData = {};
1337
- if (folderPath) linkData.path = folderPath;
1338
- if (remote) linkData.remote = remote;
1339
-
1340
- if (existingLinkIndex >= 0) {
1341
- // Update existing link
1342
- identity.links[existingLinkIndex] = linkData;
1343
- } else {
1344
- // Add new link
1345
- identity.links.push(linkData);
1346
- }
1347
-
1348
- saveConfig(config);
1349
- console.log(`✓ Updated ~/.devutils`);
1350
-
1351
- // ================================================================
1352
- // Step 2: Update ~/.ssh/config with host entry
1353
- // ================================================================
1354
- const sshKeyPath = identity.sshKey || (identity.ssh ? identity.ssh.privateKey : null);
1355
-
1356
- if (parsedRemote && sshKeyPath) {
1357
- const hostAlias = generateHostAlias(parsedRemote.host, alias);
1358
- updateSshConfig(hostAlias, parsedRemote.host, sshKeyPath, parsedRemote.port, alias);
1359
- console.log(`✓ Updated ~/.ssh/config`);
1360
- }
1361
-
1362
- // ================================================================
1363
- // Step 3: Create/update profile-specific gitconfig
1364
- // ================================================================
1365
- const profileGitconfigPath = path.join(HOME_DIR, `.gitconfig-${alias}`);
1366
-
1367
- // Collect all URL rewrites for this identity
1368
- const urlRewrites = [];
1369
- for (const link of identity.links) {
1370
- if (link.remote) {
1371
- const parsed = parseRemoteUrl(link.remote);
1372
- if (parsed) {
1373
- urlRewrites.push({
1374
- hostAlias: generateHostAlias(parsed.host, alias),
1375
- pathPrefix: parsed.pathPrefix,
1376
- host: parsed.host,
1377
- port: parsed.port
1378
- });
1379
- }
1380
- }
1381
- }
1382
-
1383
- const profileContent = generateProfileGitconfig(identity, alias, urlRewrites);
1384
- writeGitconfig(profileGitconfigPath, profileContent);
1385
- console.log(`✓ Created ~/.gitconfig-${alias}`);
1386
-
1387
- // ================================================================
1388
- // Step 4: Update ~/.gitconfig with includeIf rules
1389
- // ================================================================
1390
- updateMainGitconfig(alias, folderPath, remote);
1391
- console.log(`✓ Updated ~/.gitconfig`);
1392
-
1393
- // Summary
1394
- console.log(`\n✓ Linked identity "${alias}":`);
1395
- console.log(` Name: ${identity.name}`);
1396
- console.log(` Email: ${identity.email}`);
1397
- if (folderPath) {
1398
- console.log(` Path: ${folderPath}`);
1399
- }
1400
- if (remote) {
1401
- console.log(` Remote: ${remote}`);
1402
- }
1403
- if (sshKeyPath) {
1404
- console.log(` SSH Key: ${sshKeyPath}`);
1405
- }
1406
- console.log('');
1407
- }
1408
-
1409
- /**
1410
- * Unlink a folder path from its identity
1411
- * @param {string} folderPath - Folder path to unlink
1412
- */
1413
- async function unlinkIdentity(folderPath) {
1414
- if (!folderPath) {
1415
- console.error('\n✗ Error: Folder path is required.');
1416
- console.log('Usage: dev identity unlink <folder_path>\n');
1417
- process.exit(1);
1418
- }
1419
-
1420
- const config = loadConfig();
1421
-
1422
- // Find the link by folder path
1423
- const found = findLinkByPath(folderPath, config);
1424
-
1425
- if (!found) {
1426
- console.error(`\n✗ Error: No link found for path "${folderPath}"`);
1427
- console.log('Use `dev identity list` to see all configured links.\n');
1428
- process.exit(1);
1429
- }
1430
-
1431
- const { identity: identityAlias, link, linkIndex } = found;
1432
- const identity = config.identities[identityAlias];
1433
- const storedPath = link.path;
1434
- const storedRemote = link.remote;
1435
-
1436
- // ================================================================
1437
- // Step 1: Remove link from ~/.devutils
1438
- // ================================================================
1439
- identity.links.splice(linkIndex, 1);
1440
- saveConfig(config);
1441
- console.log(`✓ Updated ~/.devutils`);
1442
-
1443
- // ================================================================
1444
- // Step 2: Remove gitdir includeIf from ~/.gitconfig
1445
- // ================================================================
1446
- if (storedPath) {
1447
- removeGitdirIncludeIf(storedPath);
1448
- console.log(`✓ Removed includeIf rule from ~/.gitconfig`);
1449
- }
1450
-
1451
- // ================================================================
1452
- // Step 3: Regenerate profile-specific gitconfig
1453
- // ================================================================
1454
- const profileGitconfigPath = path.join(HOME_DIR, `.gitconfig-${identityAlias}`);
1455
-
1456
- // Collect remaining URL rewrites for this identity
1457
- const urlRewrites = [];
1458
- for (const remainingLink of identity.links) {
1459
- if (remainingLink.remote) {
1460
- const parsed = parseRemoteUrl(remainingLink.remote);
1461
- if (parsed) {
1462
- urlRewrites.push({
1463
- hostAlias: generateHostAlias(parsed.host, identityAlias),
1464
- pathPrefix: parsed.pathPrefix,
1465
- host: parsed.host,
1466
- port: parsed.port
1467
- });
1468
- }
1469
- }
1470
- }
1471
-
1472
- // If identity still has links or other config, regenerate the profile
1473
- // Otherwise, consider removing the profile gitconfig
1474
- if (identity.links.length > 0 || identity.sshKey || identity.ssh) {
1475
- const profileContent = generateProfileGitconfig(identity, identityAlias, urlRewrites);
1476
- writeGitconfig(profileGitconfigPath, profileContent);
1477
- console.log(`✓ Updated ~/.gitconfig-${identityAlias}`);
1478
- } else if (fs.existsSync(profileGitconfigPath)) {
1479
- // No more links and no SSH key - optionally keep the profile for future use
1480
- // For now, we keep it but update it
1481
- const profileContent = generateProfileGitconfig(identity, identityAlias, urlRewrites);
1482
- writeGitconfig(profileGitconfigPath, profileContent);
1483
- console.log(`✓ Updated ~/.gitconfig-${identityAlias}`);
1484
- }
1485
-
1486
- // Summary
1487
- console.log(`\n✓ Unlinked path from identity "${identityAlias}":`);
1488
- console.log(` Path: ${storedPath}`);
1489
- if (storedRemote) {
1490
- console.log(` Remote: ${storedRemote} (still configured via other links or profile)`);
1491
- }
1492
- console.log('');
1493
- }
1494
-
1495
- /**
1496
- * Sync all identities - regenerate config files from ~/.devutils
1497
- * This is useful after copying ~/.devutils to a new machine
1498
- */
1499
- async function syncIdentities() {
1500
- const config = loadConfig();
1501
- const identities = config.identities || {};
1502
- const identityNames = Object.keys(identities);
1503
-
1504
- if (identityNames.length === 0) {
1505
- console.log('\nNo identities configured.');
1506
- console.log('Run `dev identity add <alias>` to create one.\n');
1507
- return;
1508
- }
1509
-
1510
- console.log('\n=== Syncing Identities ===\n');
1511
-
1512
- let sshConfigUpdated = false;
1513
- let gitconfigsCreated = 0;
1514
- let includeIfRulesAdded = 0;
1515
-
1516
- for (const alias of identityNames) {
1517
- const identity = identities[alias];
1518
- const links = identity.links || [];
1519
- const sshKeyPath = identity.sshKey || (identity.ssh ? identity.ssh.privateKey : null);
1520
-
1521
- console.log(`Processing identity: ${alias}`);
1522
-
1523
- // Collect URL rewrites for this identity
1524
- const urlRewrites = [];
1525
- const processedHosts = new Set();
1526
-
1527
- for (const link of links) {
1528
- // Update SSH config for remotes
1529
- if (link.remote && sshKeyPath) {
1530
- const parsed = parseRemoteUrl(link.remote);
1531
- if (parsed && !processedHosts.has(parsed.host)) {
1532
- const hostAlias = generateHostAlias(parsed.host, alias);
1533
- updateSshConfig(hostAlias, parsed.host, sshKeyPath, parsed.port, alias);
1534
- processedHosts.add(parsed.host);
1535
- sshConfigUpdated = true;
1536
-
1537
- urlRewrites.push({
1538
- hostAlias,
1539
- pathPrefix: parsed.pathPrefix,
1540
- host: parsed.host,
1541
- port: parsed.port
1542
- });
1543
- }
1544
- }
1545
-
1546
- // Update main gitconfig with includeIf rules
1547
- if (link.path || link.remote) {
1548
- updateMainGitconfig(alias, link.path, link.remote);
1549
- includeIfRulesAdded++;
1550
- }
1551
- }
1552
-
1553
- // Create profile-specific gitconfig
1554
- if (identity.email || sshKeyPath || urlRewrites.length > 0) {
1555
- const profileGitconfigPath = path.join(HOME_DIR, `.gitconfig-${alias}`);
1556
- const profileContent = generateProfileGitconfig(identity, alias, urlRewrites);
1557
- writeGitconfig(profileGitconfigPath, profileContent);
1558
- gitconfigsCreated++;
1559
- }
1560
-
1561
- console.log(` ✓ ${links.length} link(s) processed`);
1562
- }
1563
-
1564
- // Summary
1565
- console.log('\n=== Sync Complete ===\n');
1566
- console.log(` Identities synced: ${identityNames.length}`);
1567
- if (sshConfigUpdated) {
1568
- console.log(` ✓ Updated ~/.ssh/config`);
1569
- }
1570
- console.log(` ✓ Created ${gitconfigsCreated} profile gitconfig(s)`);
1571
- console.log(` ✓ Added ${includeIfRulesAdded} includeIf rule(s) to ~/.gitconfig`);
1572
- console.log('');
1573
- }
1574
-
1575
- // Create the identity command with subcommands
1576
- const identity = new Command('identity')
1577
- .description('Manage identity profiles for git configuration and keys');
1578
-
1579
- identity
1580
- .command('add <alias>')
1581
- .description('Add a new identity profile')
1582
- .option('--name <name>', 'Name for this identity')
1583
- .option('--email <email>', 'Email for this identity')
1584
- .option('--ssh-type <type>', 'SSH key type: ed25519 (default) or rsa', 'ed25519')
1585
- .option('--force', 'Overwrite existing identity')
1586
- .action(addIdentity);
1587
-
1588
- identity
1589
- .command('update <alias>')
1590
- .description('Update an existing identity profile')
1591
- .option('--name <name>', 'New name for this identity')
1592
- .option('--email <email>', 'New email for this identity')
1593
- .action(updateIdentity);
1594
-
1595
- identity
1596
- .command('remove <alias>')
1597
- .description('Remove an identity profile')
1598
- .option('--force', 'Delete without confirmation')
1599
- .action(removeIdentity);
1600
-
1601
- identity
1602
- .command('link [args...]')
1603
- .description('Link an identity to a folder path and/or remote server')
1604
- .action(linkIdentity);
1605
-
1606
- identity
1607
- .command('unlink <folder_path>')
1608
- .description('Unlink a folder path from its identity')
1609
- .action(unlinkIdentity);
1610
-
1611
- identity
1612
- .command('sync')
1613
- .description('Regenerate all config files from ~/.devutils (for new machines)')
1614
- .action(syncIdentities);
1615
-
1616
- identity
1617
- .command('list')
1618
- .description('List all configured identities')
1619
- .action(() => {
1620
- const config = loadConfig();
1621
- listIdentities(config);
1622
- });
1623
-
1624
- // Default action (show help or list)
1625
- identity.action(() => {
1626
- const config = loadConfig();
1627
- listIdentities(config);
1628
- });
1629
-
1630
- module.exports = identity;