@fredlackey/devutils 0.0.1

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 (200) hide show
  1. package/README.md +156 -0
  2. package/bin/dev.js +16 -0
  3. package/files/README.md +0 -0
  4. package/files/claude/.claude/commands/setup-context.md +3 -0
  5. package/files/monorepos/_archive/README.md +36 -0
  6. package/files/monorepos/_legacy/README.md +36 -0
  7. package/files/monorepos/ai-docs/README.md +33 -0
  8. package/files/monorepos/apps/README.md +24 -0
  9. package/files/monorepos/docs/README.md +40 -0
  10. package/files/monorepos/packages/README.md +25 -0
  11. package/files/monorepos/research/README.md +29 -0
  12. package/files/monorepos/scripts/README.md +24 -0
  13. package/package.json +39 -0
  14. package/src/cli.js +68 -0
  15. package/src/commands/README.md +41 -0
  16. package/src/commands/configure.js +199 -0
  17. package/src/commands/identity.js +1630 -0
  18. package/src/commands/ignore.js +247 -0
  19. package/src/commands/install.js +173 -0
  20. package/src/commands/setup.js +212 -0
  21. package/src/commands/status.js +223 -0
  22. package/src/completion.js +284 -0
  23. package/src/constants.js +45 -0
  24. package/src/ignore/claude-code.txt +10 -0
  25. package/src/ignore/docker.txt +18 -0
  26. package/src/ignore/linux.txt +23 -0
  27. package/src/ignore/macos.txt +36 -0
  28. package/src/ignore/node.txt +55 -0
  29. package/src/ignore/terraform.txt +37 -0
  30. package/src/ignore/vscode.txt +18 -0
  31. package/src/ignore/windows.txt +35 -0
  32. package/src/index.js +0 -0
  33. package/src/installs/README.md +399 -0
  34. package/src/installs/adobe-creative-cloud.js +44 -0
  35. package/src/installs/appcleaner.js +44 -0
  36. package/src/installs/atomicparsley.js +44 -0
  37. package/src/installs/aws-cli.js +44 -0
  38. package/src/installs/balena-etcher.js +44 -0
  39. package/src/installs/bambu-studio.js +44 -0
  40. package/src/installs/bash-completion.js +44 -0
  41. package/src/installs/bash.js +44 -0
  42. package/src/installs/beyond-compare.js +44 -0
  43. package/src/installs/build-essential.js +44 -0
  44. package/src/installs/caffeine.js +44 -0
  45. package/src/installs/camtasia.js +44 -0
  46. package/src/installs/chatgpt.js +44 -0
  47. package/src/installs/chrome-canary.js +44 -0
  48. package/src/installs/chromium.js +44 -0
  49. package/src/installs/claude-code.js +44 -0
  50. package/src/installs/curl.js +44 -0
  51. package/src/installs/cursor.js +44 -0
  52. package/src/installs/dbschema.js +44 -0
  53. package/src/installs/docker.js +44 -0
  54. package/src/installs/drawio.js +44 -0
  55. package/src/installs/elmedia-player.js +44 -0
  56. package/src/installs/ffmpeg.js +44 -0
  57. package/src/installs/gemini-cli.js +44 -0
  58. package/src/installs/git.js +44 -0
  59. package/src/installs/gitego.js +44 -0
  60. package/src/installs/go.js +44 -0
  61. package/src/installs/google-chrome.js +44 -0
  62. package/src/installs/gpg.js +141 -0
  63. package/src/installs/homebrew.js +44 -0
  64. package/src/installs/imageoptim.js +44 -0
  65. package/src/installs/jq.js +44 -0
  66. package/src/installs/keyboard-maestro.js +44 -0
  67. package/src/installs/latex.js +44 -0
  68. package/src/installs/lftp.js +44 -0
  69. package/src/installs/messenger.js +44 -0
  70. package/src/installs/microsoft-office.js +44 -0
  71. package/src/installs/microsoft-teams.js +44 -0
  72. package/src/installs/node.js +44 -0
  73. package/src/installs/nordpass.js +44 -0
  74. package/src/installs/nvm.js +44 -0
  75. package/src/installs/openssh.js +134 -0
  76. package/src/installs/pandoc.js +44 -0
  77. package/src/installs/pinentry.js +44 -0
  78. package/src/installs/pngyu.js +44 -0
  79. package/src/installs/postman.js +44 -0
  80. package/src/installs/safari-tech-preview.js +44 -0
  81. package/src/installs/sfnt2woff.js +44 -0
  82. package/src/installs/shellcheck.js +44 -0
  83. package/src/installs/slack.js +44 -0
  84. package/src/installs/snagit.js +44 -0
  85. package/src/installs/spotify.js +44 -0
  86. package/src/installs/studio-3t.js +44 -0
  87. package/src/installs/sublime-text.js +44 -0
  88. package/src/installs/superwhisper.js +44 -0
  89. package/src/installs/tailscale.js +44 -0
  90. package/src/installs/termius.js +44 -0
  91. package/src/installs/terraform.js +44 -0
  92. package/src/installs/tidal.js +44 -0
  93. package/src/installs/tmux.js +44 -0
  94. package/src/installs/tree.js +44 -0
  95. package/src/installs/vim.js +44 -0
  96. package/src/installs/vlc.js +44 -0
  97. package/src/installs/vscode.js +44 -0
  98. package/src/installs/whatsapp.js +44 -0
  99. package/src/installs/woff2.js +44 -0
  100. package/src/installs/xcode.js +44 -0
  101. package/src/installs/yarn.js +44 -0
  102. package/src/installs/yq.js +44 -0
  103. package/src/installs/yt-dlp.js +44 -0
  104. package/src/installs/zoom.js +44 -0
  105. package/src/scripts/README.md +95 -0
  106. package/src/scripts/afk.js +23 -0
  107. package/src/scripts/backup-all.js +24 -0
  108. package/src/scripts/backup-source.js +24 -0
  109. package/src/scripts/brewd.js +23 -0
  110. package/src/scripts/brewi.js +24 -0
  111. package/src/scripts/brewr.js +24 -0
  112. package/src/scripts/brews.js +24 -0
  113. package/src/scripts/brewu.js +23 -0
  114. package/src/scripts/c.js +23 -0
  115. package/src/scripts/ccurl.js +24 -0
  116. package/src/scripts/certbot-crontab-init.js +24 -0
  117. package/src/scripts/certbot-init.js +25 -0
  118. package/src/scripts/ch.js +23 -0
  119. package/src/scripts/claude-danger.js +23 -0
  120. package/src/scripts/clean-dev.js +24 -0
  121. package/src/scripts/clear-dns-cache.js +23 -0
  122. package/src/scripts/clone.js +25 -0
  123. package/src/scripts/code-all.js +24 -0
  124. package/src/scripts/count-files.js +24 -0
  125. package/src/scripts/count-folders.js +24 -0
  126. package/src/scripts/count.js +24 -0
  127. package/src/scripts/d.js +23 -0
  128. package/src/scripts/datauri.js +24 -0
  129. package/src/scripts/delete-files.js +24 -0
  130. package/src/scripts/docker-clean.js +24 -0
  131. package/src/scripts/dp.js +23 -0
  132. package/src/scripts/e.js +24 -0
  133. package/src/scripts/empty-trash.js +23 -0
  134. package/src/scripts/evm.js +25 -0
  135. package/src/scripts/fetch-github-repos.js +25 -0
  136. package/src/scripts/get-channel.js +24 -0
  137. package/src/scripts/get-course.js +26 -0
  138. package/src/scripts/get-dependencies.js +25 -0
  139. package/src/scripts/get-folder.js +26 -0
  140. package/src/scripts/get-tunes.js +25 -0
  141. package/src/scripts/get-video.js +24 -0
  142. package/src/scripts/git-backup.js +25 -0
  143. package/src/scripts/git-clone.js +25 -0
  144. package/src/scripts/git-pup.js +23 -0
  145. package/src/scripts/git-push.js +24 -0
  146. package/src/scripts/h.js +24 -0
  147. package/src/scripts/hide-desktop-icons.js +23 -0
  148. package/src/scripts/hide-hidden-files.js +23 -0
  149. package/src/scripts/install-dependencies-from.js +25 -0
  150. package/src/scripts/ips.js +26 -0
  151. package/src/scripts/iso.js +24 -0
  152. package/src/scripts/killni.js +23 -0
  153. package/src/scripts/ll.js +24 -0
  154. package/src/scripts/local-ip.js +23 -0
  155. package/src/scripts/m.js +24 -0
  156. package/src/scripts/map.js +24 -0
  157. package/src/scripts/mkd.js +24 -0
  158. package/src/scripts/ncu-update-all.js +24 -0
  159. package/src/scripts/nginx-init.js +28 -0
  160. package/src/scripts/npmi.js +23 -0
  161. package/src/scripts/o.js +24 -0
  162. package/src/scripts/org-by-date.js +24 -0
  163. package/src/scripts/p.js +23 -0
  164. package/src/scripts/packages.js +25 -0
  165. package/src/scripts/path.js +23 -0
  166. package/src/scripts/ports.js +23 -0
  167. package/src/scripts/q.js +23 -0
  168. package/src/scripts/refresh-files.js +26 -0
  169. package/src/scripts/remove-smaller-files.js +24 -0
  170. package/src/scripts/rename-files-with-date.js +25 -0
  171. package/src/scripts/resize-image.js +25 -0
  172. package/src/scripts/rm-safe.js +24 -0
  173. package/src/scripts/s.js +24 -0
  174. package/src/scripts/set-git-public.js +23 -0
  175. package/src/scripts/show-desktop-icons.js +23 -0
  176. package/src/scripts/show-hidden-files.js +23 -0
  177. package/src/scripts/tpa.js +23 -0
  178. package/src/scripts/tpo.js +23 -0
  179. package/src/scripts/u.js +23 -0
  180. package/src/scripts/vpush.js +23 -0
  181. package/src/scripts/y.js +23 -0
  182. package/src/utils/README.md +95 -0
  183. package/src/utils/common/apps.js +143 -0
  184. package/src/utils/common/display.js +157 -0
  185. package/src/utils/common/network.js +185 -0
  186. package/src/utils/common/os.js +202 -0
  187. package/src/utils/common/package-manager.js +301 -0
  188. package/src/utils/common/privileges.js +138 -0
  189. package/src/utils/common/shell.js +195 -0
  190. package/src/utils/macos/apps.js +228 -0
  191. package/src/utils/macos/brew.js +315 -0
  192. package/src/utils/ubuntu/apt.js +301 -0
  193. package/src/utils/ubuntu/desktop.js +292 -0
  194. package/src/utils/ubuntu/snap.js +302 -0
  195. package/src/utils/ubuntu/systemd.js +286 -0
  196. package/src/utils/windows/choco.js +327 -0
  197. package/src/utils/windows/env.js +246 -0
  198. package/src/utils/windows/registry.js +269 -0
  199. package/src/utils/windows/shell.js +240 -0
  200. package/src/utils/windows/winget.js +378 -0
@@ -0,0 +1,1630 @@
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;