@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.
- package/README.md +156 -0
- package/bin/dev.js +16 -0
- package/files/README.md +0 -0
- package/files/claude/.claude/commands/setup-context.md +3 -0
- package/files/monorepos/_archive/README.md +36 -0
- package/files/monorepos/_legacy/README.md +36 -0
- package/files/monorepos/ai-docs/README.md +33 -0
- package/files/monorepos/apps/README.md +24 -0
- package/files/monorepos/docs/README.md +40 -0
- package/files/monorepos/packages/README.md +25 -0
- package/files/monorepos/research/README.md +29 -0
- package/files/monorepos/scripts/README.md +24 -0
- package/package.json +39 -0
- package/src/cli.js +68 -0
- package/src/commands/README.md +41 -0
- package/src/commands/configure.js +199 -0
- package/src/commands/identity.js +1630 -0
- package/src/commands/ignore.js +247 -0
- package/src/commands/install.js +173 -0
- package/src/commands/setup.js +212 -0
- package/src/commands/status.js +223 -0
- package/src/completion.js +284 -0
- package/src/constants.js +45 -0
- package/src/ignore/claude-code.txt +10 -0
- package/src/ignore/docker.txt +18 -0
- package/src/ignore/linux.txt +23 -0
- package/src/ignore/macos.txt +36 -0
- package/src/ignore/node.txt +55 -0
- package/src/ignore/terraform.txt +37 -0
- package/src/ignore/vscode.txt +18 -0
- package/src/ignore/windows.txt +35 -0
- package/src/index.js +0 -0
- package/src/installs/README.md +399 -0
- package/src/installs/adobe-creative-cloud.js +44 -0
- package/src/installs/appcleaner.js +44 -0
- package/src/installs/atomicparsley.js +44 -0
- package/src/installs/aws-cli.js +44 -0
- package/src/installs/balena-etcher.js +44 -0
- package/src/installs/bambu-studio.js +44 -0
- package/src/installs/bash-completion.js +44 -0
- package/src/installs/bash.js +44 -0
- package/src/installs/beyond-compare.js +44 -0
- package/src/installs/build-essential.js +44 -0
- package/src/installs/caffeine.js +44 -0
- package/src/installs/camtasia.js +44 -0
- package/src/installs/chatgpt.js +44 -0
- package/src/installs/chrome-canary.js +44 -0
- package/src/installs/chromium.js +44 -0
- package/src/installs/claude-code.js +44 -0
- package/src/installs/curl.js +44 -0
- package/src/installs/cursor.js +44 -0
- package/src/installs/dbschema.js +44 -0
- package/src/installs/docker.js +44 -0
- package/src/installs/drawio.js +44 -0
- package/src/installs/elmedia-player.js +44 -0
- package/src/installs/ffmpeg.js +44 -0
- package/src/installs/gemini-cli.js +44 -0
- package/src/installs/git.js +44 -0
- package/src/installs/gitego.js +44 -0
- package/src/installs/go.js +44 -0
- package/src/installs/google-chrome.js +44 -0
- package/src/installs/gpg.js +141 -0
- package/src/installs/homebrew.js +44 -0
- package/src/installs/imageoptim.js +44 -0
- package/src/installs/jq.js +44 -0
- package/src/installs/keyboard-maestro.js +44 -0
- package/src/installs/latex.js +44 -0
- package/src/installs/lftp.js +44 -0
- package/src/installs/messenger.js +44 -0
- package/src/installs/microsoft-office.js +44 -0
- package/src/installs/microsoft-teams.js +44 -0
- package/src/installs/node.js +44 -0
- package/src/installs/nordpass.js +44 -0
- package/src/installs/nvm.js +44 -0
- package/src/installs/openssh.js +134 -0
- package/src/installs/pandoc.js +44 -0
- package/src/installs/pinentry.js +44 -0
- package/src/installs/pngyu.js +44 -0
- package/src/installs/postman.js +44 -0
- package/src/installs/safari-tech-preview.js +44 -0
- package/src/installs/sfnt2woff.js +44 -0
- package/src/installs/shellcheck.js +44 -0
- package/src/installs/slack.js +44 -0
- package/src/installs/snagit.js +44 -0
- package/src/installs/spotify.js +44 -0
- package/src/installs/studio-3t.js +44 -0
- package/src/installs/sublime-text.js +44 -0
- package/src/installs/superwhisper.js +44 -0
- package/src/installs/tailscale.js +44 -0
- package/src/installs/termius.js +44 -0
- package/src/installs/terraform.js +44 -0
- package/src/installs/tidal.js +44 -0
- package/src/installs/tmux.js +44 -0
- package/src/installs/tree.js +44 -0
- package/src/installs/vim.js +44 -0
- package/src/installs/vlc.js +44 -0
- package/src/installs/vscode.js +44 -0
- package/src/installs/whatsapp.js +44 -0
- package/src/installs/woff2.js +44 -0
- package/src/installs/xcode.js +44 -0
- package/src/installs/yarn.js +44 -0
- package/src/installs/yq.js +44 -0
- package/src/installs/yt-dlp.js +44 -0
- package/src/installs/zoom.js +44 -0
- package/src/scripts/README.md +95 -0
- package/src/scripts/afk.js +23 -0
- package/src/scripts/backup-all.js +24 -0
- package/src/scripts/backup-source.js +24 -0
- package/src/scripts/brewd.js +23 -0
- package/src/scripts/brewi.js +24 -0
- package/src/scripts/brewr.js +24 -0
- package/src/scripts/brews.js +24 -0
- package/src/scripts/brewu.js +23 -0
- package/src/scripts/c.js +23 -0
- package/src/scripts/ccurl.js +24 -0
- package/src/scripts/certbot-crontab-init.js +24 -0
- package/src/scripts/certbot-init.js +25 -0
- package/src/scripts/ch.js +23 -0
- package/src/scripts/claude-danger.js +23 -0
- package/src/scripts/clean-dev.js +24 -0
- package/src/scripts/clear-dns-cache.js +23 -0
- package/src/scripts/clone.js +25 -0
- package/src/scripts/code-all.js +24 -0
- package/src/scripts/count-files.js +24 -0
- package/src/scripts/count-folders.js +24 -0
- package/src/scripts/count.js +24 -0
- package/src/scripts/d.js +23 -0
- package/src/scripts/datauri.js +24 -0
- package/src/scripts/delete-files.js +24 -0
- package/src/scripts/docker-clean.js +24 -0
- package/src/scripts/dp.js +23 -0
- package/src/scripts/e.js +24 -0
- package/src/scripts/empty-trash.js +23 -0
- package/src/scripts/evm.js +25 -0
- package/src/scripts/fetch-github-repos.js +25 -0
- package/src/scripts/get-channel.js +24 -0
- package/src/scripts/get-course.js +26 -0
- package/src/scripts/get-dependencies.js +25 -0
- package/src/scripts/get-folder.js +26 -0
- package/src/scripts/get-tunes.js +25 -0
- package/src/scripts/get-video.js +24 -0
- package/src/scripts/git-backup.js +25 -0
- package/src/scripts/git-clone.js +25 -0
- package/src/scripts/git-pup.js +23 -0
- package/src/scripts/git-push.js +24 -0
- package/src/scripts/h.js +24 -0
- package/src/scripts/hide-desktop-icons.js +23 -0
- package/src/scripts/hide-hidden-files.js +23 -0
- package/src/scripts/install-dependencies-from.js +25 -0
- package/src/scripts/ips.js +26 -0
- package/src/scripts/iso.js +24 -0
- package/src/scripts/killni.js +23 -0
- package/src/scripts/ll.js +24 -0
- package/src/scripts/local-ip.js +23 -0
- package/src/scripts/m.js +24 -0
- package/src/scripts/map.js +24 -0
- package/src/scripts/mkd.js +24 -0
- package/src/scripts/ncu-update-all.js +24 -0
- package/src/scripts/nginx-init.js +28 -0
- package/src/scripts/npmi.js +23 -0
- package/src/scripts/o.js +24 -0
- package/src/scripts/org-by-date.js +24 -0
- package/src/scripts/p.js +23 -0
- package/src/scripts/packages.js +25 -0
- package/src/scripts/path.js +23 -0
- package/src/scripts/ports.js +23 -0
- package/src/scripts/q.js +23 -0
- package/src/scripts/refresh-files.js +26 -0
- package/src/scripts/remove-smaller-files.js +24 -0
- package/src/scripts/rename-files-with-date.js +25 -0
- package/src/scripts/resize-image.js +25 -0
- package/src/scripts/rm-safe.js +24 -0
- package/src/scripts/s.js +24 -0
- package/src/scripts/set-git-public.js +23 -0
- package/src/scripts/show-desktop-icons.js +23 -0
- package/src/scripts/show-hidden-files.js +23 -0
- package/src/scripts/tpa.js +23 -0
- package/src/scripts/tpo.js +23 -0
- package/src/scripts/u.js +23 -0
- package/src/scripts/vpush.js +23 -0
- package/src/scripts/y.js +23 -0
- package/src/utils/README.md +95 -0
- package/src/utils/common/apps.js +143 -0
- package/src/utils/common/display.js +157 -0
- package/src/utils/common/network.js +185 -0
- package/src/utils/common/os.js +202 -0
- package/src/utils/common/package-manager.js +301 -0
- package/src/utils/common/privileges.js +138 -0
- package/src/utils/common/shell.js +195 -0
- package/src/utils/macos/apps.js +228 -0
- package/src/utils/macos/brew.js +315 -0
- package/src/utils/ubuntu/apt.js +301 -0
- package/src/utils/ubuntu/desktop.js +292 -0
- package/src/utils/ubuntu/snap.js +302 -0
- package/src/utils/ubuntu/systemd.js +286 -0
- package/src/utils/windows/choco.js +327 -0
- package/src/utils/windows/env.js +246 -0
- package/src/utils/windows/registry.js +269 -0
- package/src/utils/windows/shell.js +240 -0
- 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;
|