@bstopp/git-who 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { access, constants, readFile, writeFile } from 'node:fs/promises';
4
+ import { Profile, SIGNING_METHOD } from "../profile.js";
5
+ import { listGpgKeys } from "./gpg.js";
6
+ import { listSshKeys } from "./ssh.js";
7
+ let cache;
8
+ const CONFIG_PATH = path.join(os.homedir(), '.git-who.cfg.json');
9
+ export class NoProfilesError extends Error {
10
+ constructor() {
11
+ super('No profiles found. Use `git who add` to create one.');
12
+ this.name = 'NoProfilesError';
13
+ }
14
+ }
15
+ export class SigningKeyNotFoundError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'SigningKeyNotFoundError';
19
+ }
20
+ }
21
+ function configParseError(cause) {
22
+ return new Error(`Config file could not be parsed: ${CONFIG_PATH}`, { cause });
23
+ }
24
+ // Two access() checks rather than a single readFile+errno inspection — keeps error
25
+ // classification explicit and typed without inspecting platform error codes.
26
+ // The TOCTOU window between checks is acceptable for a single-user CLI config file.
27
+ async function readAndParseConfig() {
28
+ try {
29
+ await access(CONFIG_PATH, constants.F_OK);
30
+ }
31
+ catch {
32
+ throw new NoProfilesError();
33
+ }
34
+ try {
35
+ await access(CONFIG_PATH, constants.R_OK);
36
+ }
37
+ catch (err) {
38
+ throw new Error(`Config file could not be read: ${CONFIG_PATH}`, { cause: err });
39
+ }
40
+ let profiles;
41
+ try {
42
+ profiles = JSON.parse(await readFile(CONFIG_PATH, 'utf8'));
43
+ }
44
+ catch (err) {
45
+ throw configParseError(err);
46
+ }
47
+ if (profiles && !Array.isArray(profiles)) {
48
+ throw configParseError();
49
+ }
50
+ return profiles;
51
+ }
52
+ function invalidProfileError(index, reason, cause) {
53
+ const detail = reason ? `: ${reason}` : '';
54
+ return new Error(`Config file has an invalid profile at position ${index + 1}${detail} (${CONFIG_PATH})`, { cause });
55
+ }
56
+ /**
57
+ * Check the shape of an entry of the config file, which is unvalidated input.
58
+ *
59
+ * @throws {Error} If the entry is not an object or a field is missing or not a string.
60
+ */
61
+ function checkEntryShape(raw, index) {
62
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
63
+ throw invalidProfileError(index, 'expected an object.');
64
+ }
65
+ const obj = raw;
66
+ for (const field of ['name', 'host', 'username', 'email']) {
67
+ if (typeof obj[field] !== 'string') {
68
+ throw invalidProfileError(index, `'${field}' must be a string.`);
69
+ }
70
+ }
71
+ if (obj.signingMethod !== undefined && typeof obj.signingMethod !== 'string') {
72
+ throw invalidProfileError(index, "'signingMethod' must be a string.");
73
+ }
74
+ if (obj.signingMethod !== undefined && typeof obj.signingKey !== 'string') {
75
+ throw invalidProfileError(index, "'signingKey' must be a string when 'signingMethod' is set.");
76
+ }
77
+ return obj;
78
+ }
79
+ /**
80
+ * Build a profile from an entry of the config file.
81
+ *
82
+ * @throws {Error} If the entry has the wrong shape, or its values fail the profile's validation. The latter is thrown
83
+ * with the validation error as its `cause`.
84
+ */
85
+ function parseProfile(raw, index) {
86
+ const entry = checkEntryShape(raw, index);
87
+ const signing = entry.signingMethod !== undefined
88
+ ? { method: entry.signingMethod, key: entry.signingKey }
89
+ : undefined;
90
+ try {
91
+ return new Profile(entry.name, entry, signing);
92
+ }
93
+ catch (err) {
94
+ throw invalidProfileError(index, undefined, err);
95
+ }
96
+ }
97
+ async function saveProfiles(profiles) {
98
+ try {
99
+ await writeFile(CONFIG_PATH, JSON.stringify(profiles, null, 2), 'utf8');
100
+ cache = Promise.resolve(profiles);
101
+ }
102
+ catch (err) {
103
+ throw new Error(`Config file could not be written: ${CONFIG_PATH}`, { cause: err });
104
+ }
105
+ }
106
+ const unsetConfig = (git, key) => git.raw(['config', '--local', '--unset', key]);
107
+ async function applySigningConfig(git, profile) {
108
+ if (profile.signingKey === undefined) {
109
+ await unsetConfig(git, 'user.signingKey');
110
+ await unsetConfig(git, 'commit.gpgsign');
111
+ await unsetConfig(git, 'gpg.format');
112
+ return;
113
+ }
114
+ await git.addConfig('user.signingKey', profile.signingKey, false, 'local');
115
+ await git.addConfig('commit.gpgsign', 'true', false, 'local');
116
+ if (profile.signingMethod === SIGNING_METHOD.SSH) {
117
+ await git.addConfig('gpg.format', 'ssh', false, 'local');
118
+ }
119
+ else {
120
+ await unsetConfig(git, 'gpg.format');
121
+ }
122
+ }
123
+ export async function loadProfiles() {
124
+ if (!cache) {
125
+ cache = (async () => {
126
+ const parsed = await readAndParseConfig() || [];
127
+ const profiles = parsed.map(parseProfile);
128
+ if (profiles.length === 0) {
129
+ throw new NoProfilesError();
130
+ }
131
+ return profiles;
132
+ })();
133
+ }
134
+ try {
135
+ const profiles = await cache;
136
+ return [...profiles];
137
+ }
138
+ catch (err) {
139
+ cache = undefined;
140
+ throw err;
141
+ }
142
+ }
143
+ export async function addProfile(profile) {
144
+ let profiles = [];
145
+ try {
146
+ profiles = await loadProfiles();
147
+ }
148
+ catch (err) {
149
+ if (!(err instanceof NoProfilesError)) {
150
+ throw err;
151
+ }
152
+ }
153
+ if (profiles.some((p) => p.name === profile.name)) {
154
+ throw new Error(`Profile '${profile.name}' already exists`);
155
+ }
156
+ profiles.push(profile);
157
+ await saveProfiles(profiles);
158
+ }
159
+ export async function updateProfile(original, update) {
160
+ const profiles = await loadProfiles();
161
+ const idx = profiles.findIndex((p) => p.name === original.name);
162
+ if (idx < 0) {
163
+ throw new Error(`Profile '${original.name}' not found`);
164
+ }
165
+ profiles.splice(idx, 1, update);
166
+ await saveProfiles(profiles);
167
+ }
168
+ export async function removeProfile(name) {
169
+ if (!name) {
170
+ return;
171
+ }
172
+ let profiles;
173
+ try {
174
+ profiles = await loadProfiles();
175
+ }
176
+ catch (err) {
177
+ if (err instanceof NoProfilesError) {
178
+ return;
179
+ }
180
+ throw err;
181
+ }
182
+ const idx = profiles.findIndex((p) => p.name === name);
183
+ if (idx >= 0) {
184
+ profiles.splice(idx, 1);
185
+ await saveProfiles(profiles);
186
+ }
187
+ }
188
+ export async function getActiveProfileName(git) {
189
+ if (!await git.checkIsRepo()) {
190
+ return undefined;
191
+ }
192
+ const { value } = await git.getConfig('gitwho.profile', 'local');
193
+ return value ?? undefined;
194
+ }
195
+ /**
196
+ * Check that the profile's signing key, if it has one, exists on this machine. Nothing is written, so callers run this
197
+ * before applying a profile and can tell a failed lookup apart from a failed apply.
198
+ *
199
+ * @throws {SigningKeyNotFoundError} If the key is not found.
200
+ * @throws {Error} If the keys cannot be listed, such as when `gpg` is not installed.
201
+ */
202
+ export async function verifySigningKey(profile) {
203
+ if (profile.signingKey === undefined) {
204
+ return;
205
+ }
206
+ if (profile.signingMethod === SIGNING_METHOD.GPG) {
207
+ const keys = await listGpgKeys(profile.signingKey);
208
+ if (keys.length === 0) {
209
+ throw new SigningKeyNotFoundError(`GPG key '${profile.signingKey}' not found for profile '${profile.name}'.`);
210
+ }
211
+ }
212
+ else if (profile.signingMethod === SIGNING_METHOD.SSH) {
213
+ const sshKeys = await listSshKeys();
214
+ if (!sshKeys.includes(profile.signingKey)) {
215
+ throw new SigningKeyNotFoundError(`SSH key '${profile.signingKey}' not found for profile '${profile.name}'.`);
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * Write the profile to the repo's local git config, and set the origin remote url when one is given. The signing key
221
+ * is not checked here; call `verifySigningKey` first.
222
+ */
223
+ export async function applyProfile(git, profile, originUrl) {
224
+ await git.addConfig('gitwho.profile', profile.name, false, 'local');
225
+ await git.addConfig('user.name', profile.username, false, 'local');
226
+ await git.addConfig('user.email', profile.email, false, 'local');
227
+ await applySigningConfig(git, profile);
228
+ if (originUrl) {
229
+ await git.remote(['set-url', 'origin', originUrl]);
230
+ }
231
+ }
@@ -0,0 +1,171 @@
1
+ import chalk from 'chalk';
2
+ import input from '@inquirer/input';
3
+ import select from '@inquirer/select';
4
+ import log from "./log.js";
5
+ import { Profile, SIGNING_METHOD } from "../profile.js";
6
+ import { listSshHosts, listSshKeys } from "./ssh.js";
7
+ import { listGpgKeys } from "./gpg.js";
8
+ import { validateProfileName, validateUnique, validateUsername, validateEmail, } from "./validators.js";
9
+ import { loadProfiles, NoProfilesError } from "./profiles.js";
10
+ async function loadExistingProfiles() {
11
+ try {
12
+ return await loadProfiles();
13
+ }
14
+ catch (err) {
15
+ if (err instanceof NoProfilesError) {
16
+ return [];
17
+ }
18
+ throw err;
19
+ }
20
+ }
21
+ function inquirerValidate(fn) {
22
+ function validate(value) {
23
+ try {
24
+ fn(value);
25
+ return true;
26
+ }
27
+ catch (err) {
28
+ return err.message;
29
+ }
30
+ }
31
+ return validate;
32
+ }
33
+ async function nameInput(existing, current) {
34
+ return input({
35
+ message: 'Profile name:',
36
+ default: current,
37
+ validate: inquirerValidate((value) => {
38
+ validateProfileName(value);
39
+ if (existing && existing.length > 0 && value !== current) {
40
+ validateUnique(existing, value);
41
+ }
42
+ }),
43
+ });
44
+ }
45
+ async function hostInput(current) {
46
+ const hosts = await listSshHosts();
47
+ const choices = [];
48
+ hosts.forEach((entry) => {
49
+ choices.push({
50
+ value: entry.host,
51
+ name: `${entry.host} (${entry.hostname})`,
52
+ });
53
+ });
54
+ return select({
55
+ message: 'SSH Host:',
56
+ default: current,
57
+ choices,
58
+ });
59
+ }
60
+ async function signInput(current = 'ssh') {
61
+ const choices = [
62
+ { name: 'Use SSH', value: SIGNING_METHOD.SSH },
63
+ { name: 'Use GPG', value: SIGNING_METHOD.GPG },
64
+ { name: 'No', value: 'no' },
65
+ ];
66
+ return select({
67
+ message: 'Sign Commits?',
68
+ default: current,
69
+ choices,
70
+ });
71
+ }
72
+ async function selectUid(key) {
73
+ if (key.uids.length === 1) {
74
+ log.info(`Using identity: ${key.uids[0].name} (${key.uids[0].email})`);
75
+ return key.uids[0];
76
+ }
77
+ return select({
78
+ message: 'Select an identity:',
79
+ choices: key.uids.map((uid) => ({
80
+ name: `${uid.name} (${uid.email})`,
81
+ value: uid,
82
+ })),
83
+ });
84
+ }
85
+ async function selectGpgKey(current) {
86
+ const keys = await listGpgKeys();
87
+ if (keys.length === 0) {
88
+ throw new Error('No GPG keys found on this system.');
89
+ }
90
+ let key;
91
+ if (keys.length === 1) {
92
+ log.info(`Using GPG key: ${keys[0].keyId}`);
93
+ key = keys[0];
94
+ }
95
+ else {
96
+ const keyId = await select({
97
+ message: 'Select a GPG key:',
98
+ choices: keys.map((k) => ({
99
+ name: `${k.keyId} — ${k.uids.map((u) => `${u.name} (${u.email})`).join(', ')}`,
100
+ value: k.keyId,
101
+ })),
102
+ default: current,
103
+ });
104
+ key = keys.find((entry) => entry.keyId === keyId);
105
+ }
106
+ const uid = await selectUid(key);
107
+ return { key, uid };
108
+ }
109
+ async function selectSshKey(current) {
110
+ const keys = await listSshKeys();
111
+ if (keys.length === 0) {
112
+ throw new Error('No SSH keys found on this system.');
113
+ }
114
+ return select({
115
+ message: 'Select an SSH key:',
116
+ default: current,
117
+ choices: keys,
118
+ });
119
+ }
120
+ async function usernameInput(current) {
121
+ return input({
122
+ message: 'Git user.name:',
123
+ default: current,
124
+ validate: inquirerValidate(validateUsername),
125
+ });
126
+ }
127
+ async function emailInput(current) {
128
+ return input({
129
+ message: 'Git user.email:',
130
+ default: current,
131
+ validate: inquirerValidate(validateEmail),
132
+ });
133
+ }
134
+ async function promptSignCommits(editing) {
135
+ // A profile being edited keeps its current choice, which is 'no' when it has no signing; a new one defaults to ssh.
136
+ const method = await signInput(editing ? editing.signingMethod ?? 'no' : undefined);
137
+ let signingData;
138
+ let uid;
139
+ if (method === SIGNING_METHOD.GPG) {
140
+ let key;
141
+ ({ key, uid } = await selectGpgKey(editing?.signingKey));
142
+ signingData = { method, key: key.keyId };
143
+ }
144
+ else if (method === SIGNING_METHOD.SSH) {
145
+ const key = await selectSshKey(editing?.signingKey);
146
+ signingData = { method, key };
147
+ }
148
+ return { signingData, uid };
149
+ }
150
+ export async function promptForProfile(editing) {
151
+ const existing = await loadExistingProfiles();
152
+ const name = await nameInput(existing, editing?.name);
153
+ const host = await hostInput(editing?.host);
154
+ const { signingData, uid } = await promptSignCommits(editing);
155
+ const username = await usernameInput(uid?.name || editing?.username);
156
+ const email = await emailInput(uid?.email || editing?.email);
157
+ return new Profile(name, { host, username, email }, signingData);
158
+ }
159
+ export async function selectProfile(profiles, name) {
160
+ if (name) {
161
+ const found = profiles.find((p) => p.name === name);
162
+ if (found) {
163
+ return found;
164
+ }
165
+ log.warn(`Profile ${chalk.bold(name)} not found, please select one:`);
166
+ }
167
+ return select({
168
+ message: 'Select profile:',
169
+ choices: profiles.map((p) => ({ name: p.name, value: p })),
170
+ });
171
+ }
@@ -0,0 +1,80 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile, readdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import log from "./log.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const isMissing = (err) => err.code === 'ENOENT';
9
+ function parseSshSearch(host, stdout) {
10
+ const hostname = stdout.split('\n')
11
+ .find((line) => line.toLowerCase().startsWith('hostname'))
12
+ .split(' ')[1];
13
+ return { host, hostname };
14
+ }
15
+ async function readConfig() {
16
+ const path = join(homedir(), '.ssh', 'config');
17
+ return readFile(path, 'utf-8');
18
+ }
19
+ async function buildHostsResult(hosts) {
20
+ const promises = hosts.map(async (host) => {
21
+ return execFileAsync('ssh', ['-G', host])
22
+ .then((result) => {
23
+ return parseSshSearch(host, result.stdout);
24
+ })
25
+ .catch((err) => {
26
+ log.warn(`Unable to look up host '${host}': ${err.message}`);
27
+ });
28
+ });
29
+ return (await Promise.all(promises)).filter((v) => v !== undefined);
30
+ }
31
+ /**
32
+ * List SSH host aliases configured in ~/.ssh/config, resolved via `ssh -G`.
33
+ *
34
+ * @throws {Error} If no host aliases can be found, including when ~/.ssh/config does not exist (kept as the `cause`).
35
+ */
36
+ export async function listSshHosts() {
37
+ let configs;
38
+ try {
39
+ configs = await readConfig();
40
+ }
41
+ catch (err) {
42
+ if (isMissing(err)) {
43
+ throw new Error('No SSH host aliases found in ~/.ssh/config.', { cause: err });
44
+ }
45
+ throw err;
46
+ }
47
+ const hosts = [];
48
+ for (const line of configs.split('\n')) {
49
+ const match = line.trim().match(/^Host\s+([^#]+).*$/i);
50
+ if (match) {
51
+ match[1].split(/\s+/).filter((s) => s).forEach((host) => {
52
+ if (host && !host.startsWith('!') && !host.match(/[?*]/) && !hosts.includes(host)) {
53
+ hosts.push(host);
54
+ }
55
+ });
56
+ }
57
+ }
58
+ const results = await buildHostsResult(hosts);
59
+ if (results.length === 0) {
60
+ throw new Error('No SSH host aliases found in ~/.ssh/config.');
61
+ }
62
+ return results;
63
+ }
64
+ /**
65
+ * List SSH public key files (*.pub) found in ~/.ssh. A missing ~/.ssh has no keys, so it gives an empty list.
66
+ */
67
+ export async function listSshKeys() {
68
+ const path = join(homedir(), '.ssh');
69
+ let files;
70
+ try {
71
+ files = await readdir(path);
72
+ }
73
+ catch (err) {
74
+ if (isMissing(err)) {
75
+ return [];
76
+ }
77
+ throw err;
78
+ }
79
+ return files.filter((f) => f.endsWith('.pub')).map((f) => join(path, f)).sort();
80
+ }
@@ -0,0 +1,59 @@
1
+ const HOST_RE = /^[A-Za-z0-9_\-.]+$/;
2
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3
+ const GPG_SIGNING_KEY_RE = /^[A-Z0-9]+$/;
4
+ /**
5
+ * @throws {Error} If value contains characters other than letters, numbers, dash, dot, or underscore.
6
+ */
7
+ export function validateProfileName(value) {
8
+ if (!HOST_RE.test(value)) {
9
+ throw new Error('Profile name must contain only letters, numbers, dash, dot, or underscore.');
10
+ }
11
+ }
12
+ /**
13
+ * @throws {Error} If value is empty or contains characters other than letters, numbers, dash, dot, or underscore.
14
+ */
15
+ export function validateHost(value) {
16
+ if (!HOST_RE.test(value)) {
17
+ throw new Error('Host must contain only letters, numbers, dash, dot, or underscore.');
18
+ }
19
+ }
20
+ /**
21
+ * @throws {Error} If value is empty or only whitespace.
22
+ */
23
+ export function validateUsername(value) {
24
+ if (!value.trim()) {
25
+ throw new Error('Name must not be blank.');
26
+ }
27
+ }
28
+ /**
29
+ * @throws {Error} If value is not a valid email address.
30
+ */
31
+ export function validateEmail(value) {
32
+ if (!EMAIL_RE.test(value)) {
33
+ throw new Error('Must be a valid email address.');
34
+ }
35
+ }
36
+ /**
37
+ * @throws {Error} If value is present but contains characters other than uppercase letters and numbers.
38
+ */
39
+ export function validateGpgSigningKey(value) {
40
+ if (!GPG_SIGNING_KEY_RE.test(value)) {
41
+ throw new Error('GPG Signing key must contain only uppercase letters and numbers.');
42
+ }
43
+ }
44
+ /**
45
+ * @throws {Error} If value is not present.
46
+ */
47
+ export function validateSshSigningKey(value) {
48
+ if (!value) {
49
+ throw new Error('SSH Signing key must be specified.');
50
+ }
51
+ }
52
+ /**
53
+ * @throws {Error} If value is already a key in existing.
54
+ */
55
+ export function validateUnique(existing, name) {
56
+ if (existing.some((p) => p.name === name)) {
57
+ throw new Error(`Profile '${name}' already exists.`);
58
+ }
59
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@bstopp/git-who",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Manage git profiles for per-project SSH account configuration.",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/bstopp/git-who.git"
10
+ },
11
+ "homepage": "https://github.com/bstopp/git-who#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/bstopp/git-who/issues"
14
+ },
15
+ "bin": {
16
+ "git-who": "dist/bin/git-who.js"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
26
+ "prepack": "npm run build",
27
+ "lint": "eslint",
28
+ "test": "c8 mocha 'test/**/*.test.ts'",
29
+ "test:e2e": "mocha --node-option experimental-test-module-mocks 'test/e2e/**/*.e2e.ts'",
30
+ "typecheck": "tsc"
31
+ },
32
+ "engines": {
33
+ "node": ">=24"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
37
+ "@inquirer/testing": "^3.3.10",
38
+ "@stylistic/eslint-plugin": "^5.10.0",
39
+ "@types/mocha": "^10.0.10",
40
+ "@types/node": "^24.19.2",
41
+ "c8": "^12.0.0",
42
+ "esmock": "^3.0.0",
43
+ "mocha": "^12.0.3",
44
+ "semantic-release": "^25.0.9",
45
+ "typescript": "^6.0.3",
46
+ "typescript-eslint": "^8.65.0"
47
+ },
48
+ "dependencies": {
49
+ "@inquirer/confirm": "^6.3.5",
50
+ "@inquirer/core": "^12.0.6",
51
+ "@inquirer/input": "^5.1.2",
52
+ "@inquirer/select": "^5.2.1",
53
+ "chalk": "^6.0.0",
54
+ "commander": "^15.0.0",
55
+ "simple-git": "^4.0.2"
56
+ }
57
+ }