@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,36 @@
1
+ import chalk from 'chalk';
2
+ import confirm from '@inquirer/confirm';
3
+ import log from "../utils/log.js";
4
+ import { loadProfiles, updateProfile } from "../utils/profiles.js";
5
+ import { promptForProfile, selectProfile } from "../utils/prompts.js";
6
+ import { printProfile } from "../utils/display.js";
7
+ /**
8
+ * Edit a profile, either the one given by name or one picked from a prompt. The prompts are pre-filled with the current
9
+ * values. If nothing changed the user is offered another pass; otherwise the changes are printed next to their original
10
+ * values and must be confirmed before they are saved. Renaming a profile warns that repos still using the old name need
11
+ * `git who use` to pick up the new one, as the name is what links a repo to its profile.
12
+ *
13
+ * @throws {Error} If the profiles cannot be loaded or the profile cannot be updated.
14
+ */
15
+ export default async function edit(name) {
16
+ const profiles = await loadProfiles();
17
+ const selected = await selectProfile(profiles, name);
18
+ let updated;
19
+ let changed;
20
+ do {
21
+ updated = await promptForProfile(selected);
22
+ changed = JSON.stringify(selected) !== JSON.stringify(updated);
23
+ } while (!changed && await confirm({ message: 'No changes detected. Return to edit?', default: true }));
24
+ if (changed) {
25
+ printProfile(updated, { original: selected });
26
+ if (await confirm({ message: 'Save this profile?', default: true })) {
27
+ await updateProfile(selected, updated);
28
+ log.info(`Profile ${chalk.bold(updated.name)} updated.`);
29
+ if (updated.name !== selected.name) {
30
+ log.warn(`Profile renamed from '${selected.name}' to '${updated.name}'. Repos still using '${selected.name}' need \`git who use ${updated.name}\` to pick up the new name.`);
31
+ }
32
+ return;
33
+ }
34
+ }
35
+ log.warn('Profile not updated.');
36
+ }
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+ import { simpleGit } from 'simple-git';
3
+ import log from "../utils/log.js";
4
+ import { loadProfiles, getActiveProfileName } from "../utils/profiles.js";
5
+ export default async function list() {
6
+ const profiles = await loadProfiles();
7
+ const activeName = await getActiveProfileName(simpleGit());
8
+ const lines = profiles.map((p) => {
9
+ if (p.name === activeName) {
10
+ return chalk.bold(` - ${p.name} (${p.host})`);
11
+ }
12
+ return ` - ${p.name} (${p.host})`;
13
+ });
14
+ lines.unshift('Known profiles:');
15
+ log.info(lines.join('\n'));
16
+ }
@@ -0,0 +1,57 @@
1
+ import chalk from 'chalk';
2
+ import confirm from '@inquirer/confirm';
3
+ import { simpleGit } from 'simple-git';
4
+ import log from "../utils/log.js";
5
+ import { loadProfiles, removeProfile, getActiveProfileName } from "../utils/profiles.js";
6
+ import { selectProfile } from "../utils/prompts.js";
7
+ import { printProfile } from "../utils/display.js";
8
+ async function confirmContinue(profile) {
9
+ return confirm({
10
+ message: `Profile '${profile.name}' is the active profile. Continue?`,
11
+ theme: { style: { message: (text) => chalk.yellow(text) } },
12
+ default: false,
13
+ });
14
+ }
15
+ async function confirmRemove(profile) {
16
+ return confirm({
17
+ message: `Remove profile '${profile.name}'?`,
18
+ theme: { style: { message: (text) => chalk.red(text) } },
19
+ default: false,
20
+ });
21
+ }
22
+ async function unsetActive(git) {
23
+ try {
24
+ await git.raw(['config', '--local', '--unset', 'gitwho.profile']);
25
+ }
26
+ catch {
27
+ log.warn("Unable to unset 'gitwho.profile' git config value.");
28
+ }
29
+ }
30
+ /**
31
+ * Remove a profile, either the one given by name or one picked from a prompt. The profile is printed and the removal
32
+ * must be confirmed. Removing the profile that is active in the current repo needs an extra confirmation, and unsets
33
+ * `gitwho.profile` afterwards; a failure to unset it only warns.
34
+ *
35
+ * @throws {Error} If the profiles cannot be loaded or the profile cannot be removed.
36
+ */
37
+ export default async function remove(name) {
38
+ const git = simpleGit();
39
+ const profiles = await loadProfiles();
40
+ const selected = await selectProfile(profiles, name);
41
+ const isActive = await getActiveProfileName(git) === selected.name;
42
+ if (isActive && !await confirmContinue(selected)) {
43
+ log.warn(`Profile '${selected.name}' not removed, as it is currently active.`);
44
+ return;
45
+ }
46
+ printProfile(selected);
47
+ if (await confirmRemove(selected)) {
48
+ await removeProfile(selected.name);
49
+ if (isActive) {
50
+ await unsetActive(git);
51
+ }
52
+ log.info(`Profile '${selected.name}' has been removed.`);
53
+ }
54
+ else {
55
+ log.warn(`Profile '${selected.name}' not removed.`);
56
+ }
57
+ }
@@ -0,0 +1,159 @@
1
+ import { simpleGit } from 'simple-git';
2
+ import { SIGNING_METHOD } from "../profile.js";
3
+ import log from "../utils/log.js";
4
+ import { printProfile } from "../utils/display.js";
5
+ import { loadProfiles, getActiveProfileName } from "../utils/profiles.js";
6
+ import { listSshKeys } from "../utils/ssh.js";
7
+ import { listGpgKeys } from "../utils/gpg.js";
8
+ /**
9
+ * Compare a profile with the repo's local git config. A field is only present in the result when the two differ, and
10
+ * its value is what git has (`undefined` when git has nothing set).
11
+ *
12
+ * Git's signing method is only defined when `commit.gpgsign` is `true`: `gpg.format` of `openpgp` (or unset) is gpg,
13
+ * `ssh` is ssh. Any other format, such as x509, is not supported and reads as git not signing.
14
+ */
15
+ function buildMismatches(actual, git) {
16
+ const mismatches = {};
17
+ if (actual.username !== git.username) {
18
+ mismatches.username = git.username;
19
+ }
20
+ if (actual.email !== git.email) {
21
+ mismatches.email = git.email;
22
+ }
23
+ if (actual.signingKey !== git.signingKey) {
24
+ mismatches.signingKey = git.signingKey;
25
+ }
26
+ let gitSigningMethod;
27
+ if (git.signCommits === 'true') {
28
+ if (!git.signingMethod || git.signingMethod === 'openpgp') {
29
+ gitSigningMethod = SIGNING_METHOD.GPG;
30
+ }
31
+ else if (git.signingMethod === 'ssh') {
32
+ gitSigningMethod = SIGNING_METHOD.SSH;
33
+ }
34
+ }
35
+ if (actual.signingMethod !== gitSigningMethod) {
36
+ mismatches.signingMethod = gitSigningMethod;
37
+ }
38
+ return mismatches;
39
+ }
40
+ async function readGitConfig() {
41
+ const git = simpleGit();
42
+ const [name, { value: username }, { value: email }, { value: signCommits }, { value: signingKey }, { value: signingMethod },] = await Promise.all([
43
+ getActiveProfileName(git),
44
+ git.getConfig('user.name', 'local'),
45
+ git.getConfig('user.email', 'local'),
46
+ git.getConfig('commit.gpgsign', 'local'),
47
+ git.getConfig('user.signingKey', 'local'),
48
+ git.getConfig('gpg.format', 'local'),
49
+ ]);
50
+ return {
51
+ name,
52
+ username: username ?? undefined,
53
+ email: email ?? undefined,
54
+ signCommits: signCommits ?? undefined,
55
+ signingKey: signingKey ?? undefined,
56
+ signingMethod: signingMethod ?? undefined,
57
+ };
58
+ }
59
+ /**
60
+ * Find the profile for the current repo: the one named by `gitwho.profile`, otherwise the single profile whose
61
+ * username and email match the git config (flagged as derived).
62
+ *
63
+ * @throws {Error} If `gitwho.profile` names a missing profile and nothing else matches uniquely, if several profiles
64
+ * match with no active profile set, or if no profile is set and none match. A missing active profile takes priority
65
+ * over multiple matches, since it is the underlying cause.
66
+ */
67
+ async function resolveFromGit(profiles) {
68
+ const gitData = await readGitConfig();
69
+ const profile = profiles.find((p) => p.name === gitData.name);
70
+ if (profile) {
71
+ const mismatches = buildMismatches(profile, gitData);
72
+ return { profile, derived: false, mismatches };
73
+ }
74
+ const matches = profiles.filter((p) => p.username === gitData.username && p.email === gitData.email);
75
+ if (matches.length === 1) {
76
+ const mismatches = buildMismatches(matches[0], gitData);
77
+ return { profile: matches[0], derived: true, mismatches };
78
+ }
79
+ else if (gitData.name) {
80
+ throw new Error(`Active profile '${gitData.name}' not found — it may have been deleted.`);
81
+ }
82
+ else if (matches.length > 1) {
83
+ throw new Error('Multiple profiles match the current git config — set one explicitly with `git who use <profile>`.');
84
+ }
85
+ throw new Error('No profile set and none found matching the current git config.');
86
+ }
87
+ async function buildStatus(resolved) {
88
+ let signingKeyStatus;
89
+ if (resolved.profile.signingMethod === SIGNING_METHOD.SSH) {
90
+ const keys = await listSshKeys();
91
+ if (resolved.profile.signingKey && !keys.includes(resolved.profile.signingKey)) {
92
+ signingKeyStatus = {
93
+ found: false,
94
+ };
95
+ }
96
+ else {
97
+ signingKeyStatus = {
98
+ found: true,
99
+ };
100
+ }
101
+ }
102
+ else if (resolved.profile.signingMethod === SIGNING_METHOD.GPG) {
103
+ const gpgKeys = await listGpgKeys(resolved.profile.signingKey);
104
+ if (gpgKeys.length > 0) {
105
+ signingKeyStatus = {
106
+ found: true,
107
+ uids: gpgKeys[0].uids,
108
+ };
109
+ }
110
+ else {
111
+ signingKeyStatus = { found: false };
112
+ }
113
+ }
114
+ return signingKeyStatus;
115
+ }
116
+ async function resolveProfile(name, profiles) {
117
+ if (name) {
118
+ const profile = profiles.find((p) => p.name === name);
119
+ if (!profile) {
120
+ throw new Error(`Profile ${name} not found.`);
121
+ }
122
+ return {
123
+ profile,
124
+ derived: false,
125
+ };
126
+ }
127
+ else {
128
+ return await resolveFromGit(profiles);
129
+ }
130
+ }
131
+ /**
132
+ * Print a profile, either the one given by name or the one resolved from the current repo. A profile resolved from the
133
+ * repo is also checked against its git config, and any signing key is looked up to confirm it exists.
134
+ *
135
+ * @throws {Error} If no name is given outside a git repo, the named profile does not exist, or no profile can be
136
+ * resolved from the repo. Errors from the ssh and gpg key lookups are not caught.
137
+ */
138
+ export default async function show(name) {
139
+ const git = simpleGit();
140
+ if (!name && !(await git.checkIsRepo())) {
141
+ throw new Error('You must provide a profile name, or invoke the show command from a git repo.');
142
+ }
143
+ const profiles = await loadProfiles();
144
+ const resolved = await resolveProfile(name, profiles);
145
+ const signingKeyStatus = await buildStatus(resolved);
146
+ let opts = undefined;
147
+ if (resolved.mismatches && Object.keys(resolved.mismatches).length > 0) {
148
+ opts ||= {};
149
+ opts.mismatches = resolved.mismatches;
150
+ }
151
+ if (signingKeyStatus) {
152
+ opts ||= {};
153
+ opts.signingKeyStatus = signingKeyStatus;
154
+ }
155
+ printProfile(resolved.profile, opts);
156
+ if (resolved.derived) {
157
+ log.warn(`Profile derived from git config — not explicitly set via gitwho.profile`);
158
+ }
159
+ }
@@ -0,0 +1,64 @@
1
+ import chalk from 'chalk';
2
+ import confirm from '@inquirer/confirm';
3
+ import { makeTheme } from '@inquirer/core';
4
+ import { simpleGit } from 'simple-git';
5
+ import log from "../utils/log.js";
6
+ import { selectProfile } from "../utils/prompts.js";
7
+ import { applyProfile, loadProfiles, verifySigningKey } from "../utils/profiles.js";
8
+ import { parseSshUrl } from "../utils/git-url.js";
9
+ const HTTPS_URL_PATTERN = /^https?:\/\/[^/]+\/(.+)$/;
10
+ function extractRepoPath(url) {
11
+ return parseSshUrl(url)?.path ?? url.match(HTTPS_URL_PATTERN)?.[1];
12
+ }
13
+ /**
14
+ * Work out the origin remote url to set, if any. When the origin does not use the profile's ssh host, the user is asked
15
+ * whether to rewrite it to `git@<host>:<path>`.
16
+ *
17
+ * @returns The url to set, or `undefined` to leave the origin alone (no origin, already matching, or declined).
18
+ * @throws {Error} If the origin is neither an ssh nor an http(s) url, so its repo path cannot be determined.
19
+ */
20
+ async function resolveOriginUrl(git, profile) {
21
+ const { value: currentUrl } = await git.getConfig('remote.origin.url', 'local');
22
+ if (!currentUrl) {
23
+ return undefined;
24
+ }
25
+ const path = extractRepoPath(currentUrl);
26
+ if (!path) {
27
+ throw new Error(`Unable to determine the repo path from origin remote '${currentUrl}'.`);
28
+ }
29
+ const desiredUrl = `git@${profile.host}:${path}`;
30
+ if (desiredUrl === currentUrl) {
31
+ return undefined;
32
+ }
33
+ const { style } = makeTheme();
34
+ const updateOrigin = await confirm({
35
+ message: `Origin remote is ${style.highlight(currentUrl)}, but profile '${profile.name}' expects ${style.highlight(desiredUrl)}. Update origin to match?`,
36
+ default: true,
37
+ });
38
+ return updateOrigin ? desiredUrl : undefined;
39
+ }
40
+ /**
41
+ * Apply a profile to the current repo, either the one given by name or one picked from a prompt.
42
+ *
43
+ * @throws {Error} If the current directory is not a git repo, the profile's signing key is missing or cannot be looked
44
+ * up, the origin remote url cannot be parsed, or applying the profile fails. The signing key is checked first and its
45
+ * failure is thrown as is, because nothing has been written by then. A failure to apply is wrapped with its `cause`,
46
+ * since the git config may be left partly updated.
47
+ */
48
+ export default async function use(name) {
49
+ const git = simpleGit();
50
+ if (!await git.checkIsRepo()) {
51
+ throw new Error('The current directory must have a git repository to use a profile.');
52
+ }
53
+ const profiles = await loadProfiles();
54
+ const profile = await selectProfile(profiles, name);
55
+ await verifySigningKey(profile);
56
+ const originUrl = await resolveOriginUrl(git, profile);
57
+ try {
58
+ await applyProfile(git, profile, originUrl);
59
+ }
60
+ catch (error) {
61
+ throw new Error(`Failed to apply profile '${profile.name}'. Git config may be partly updated; re-run to retry.`, { cause: error });
62
+ }
63
+ log.info(`Now using profile '${chalk.bold(profile.name)}'.`);
64
+ }
@@ -0,0 +1,33 @@
1
+ import { validateProfileName, validateHost, validateUsername, validateEmail, validateSshSigningKey, validateGpgSigningKey, } from "./utils/validators.js";
2
+ export const SIGNING_METHOD = { GPG: 'gpg', SSH: 'ssh' };
3
+ const SIGNING_KEY_VALIDATORS = {
4
+ [SIGNING_METHOD.SSH]: validateSshSigningKey,
5
+ [SIGNING_METHOD.GPG]: validateGpgSigningKey,
6
+ };
7
+ export class Profile {
8
+ name;
9
+ host;
10
+ username;
11
+ email;
12
+ signingMethod;
13
+ signingKey;
14
+ constructor(name, git, signing) {
15
+ validateProfileName(name);
16
+ this.name = name;
17
+ validateHost(git.host);
18
+ this.host = git.host;
19
+ validateUsername(git.username);
20
+ this.username = git.username;
21
+ validateEmail(git.email);
22
+ this.email = git.email;
23
+ if (signing) {
24
+ const validate = SIGNING_KEY_VALIDATORS[signing.method];
25
+ if (!validate) {
26
+ throw new Error(`Invalid signing method: '${signing.method}'. Value must be one of: '${Object.values(SIGNING_METHOD).join("', '")}'.`);
27
+ }
28
+ validate(signing.key);
29
+ this.signingMethod = signing.method;
30
+ this.signingKey = signing.key;
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,46 @@
1
+ import chalk from 'chalk';
2
+ import log from "./log.js";
3
+ export const PROFILE_FIELDS = { USERNAME: 'username', EMAIL: 'email', SIGNING_KEY: 'signingKey', SIGNING_METHOD: 'signingMethod' };
4
+ function formatSigningKey(keyId, uids) {
5
+ if (uids.length === 1) {
6
+ return `${keyId}, ${uids[0].name} (${uids[0].email})`;
7
+ }
8
+ return [keyId, ...uids.map((u) => ` ${u.name} (${u.email})`)].join('\n');
9
+ }
10
+ function buildSigningKey(signingKey, signingStatus) {
11
+ const keyText = signingStatus?.uids
12
+ ? formatSigningKey(signingKey, signingStatus.uids)
13
+ : signingKey;
14
+ return signingStatus && !signingStatus.found
15
+ ? chalk.yellow(`${keyText} (not found)`)
16
+ : keyText;
17
+ }
18
+ const signingText = (method) => method ? `Using ${method}` : 'No';
19
+ const specifiedOrNot = (key) => key || 'Not Specified';
20
+ // Appends the original value of a field when it differs from the current one.
21
+ const wasSuffix = (oldVal, newVal, format = (value) => `${value}`, separator = ' ') => oldVal === newVal ? '' : `${separator}(was: ${format(oldVal)})`;
22
+ export function printProfile(profile, opts) {
23
+ const signingStatus = opts?.signingKeyStatus;
24
+ const mismatches = opts?.mismatches;
25
+ const prev = opts?.original ?? profile; // compared with itself, so nothing is annotated without an original
26
+ const label = (s) => chalk.bold(s + ':');
27
+ const field = (key, val) => {
28
+ return key in (mismatches ?? {}) ? chalk.yellow(`${val} *`) : val;
29
+ };
30
+ const lines = [
31
+ `${label('Profile')} ${profile.name}${wasSuffix(prev.name, profile.name)}`,
32
+ `${label('Host')} ${profile.host}${wasSuffix(prev.host, profile.host)}`,
33
+ `${label('Name')} ${field(PROFILE_FIELDS.USERNAME, profile.username)}${wasSuffix(prev.username, profile.username)}`,
34
+ `${label('Email')} ${field(PROFILE_FIELDS.EMAIL, profile.email)}${wasSuffix(prev.email, profile.email)}`,
35
+ `${label('Sign Commits')} ${signingText(profile.signingMethod)}${wasSuffix(prev.signingMethod, profile.signingMethod, signingText)}`,
36
+ ];
37
+ if (profile.signingKey) {
38
+ const display = buildSigningKey(profile.signingKey, signingStatus);
39
+ lines.push(`${label('Signing Key')} ${display}${wasSuffix(prev.signingKey, profile.signingKey, specifiedOrNot, '\n')}`);
40
+ }
41
+ log.info(lines.join('\n'));
42
+ if (mismatches && Object.keys(mismatches).length > 0) {
43
+ const parts = Object.entries(mismatches).map(([k, v]) => `${k}=${v ?? ''}`).join(', ');
44
+ log.warn(`Git config does not match profile '${profile.name}' (Git config values: ${parts})`);
45
+ }
46
+ }
@@ -0,0 +1,9 @@
1
+ const SCP_URL_PATTERN = /^git@([^:]+):(.+)$/;
2
+ const SSH_URL_PATTERN = /^ssh:\/\/git@([^:/]+)(?::\d+)?\/(.+)$/;
3
+ export function parseSshUrl(url) {
4
+ const match = url.match(SCP_URL_PATTERN) || url.match(SSH_URL_PATTERN);
5
+ if (!match) {
6
+ return undefined;
7
+ }
8
+ return { host: match[1], path: match[2] };
9
+ }
@@ -0,0 +1,62 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const VALID_UID = new Set(['u', 'f', 'm', 'q', '-']);
5
+ function parseUid(line) {
6
+ const validity = line.split(':')[1];
7
+ if (!VALID_UID.has(validity)) {
8
+ return null;
9
+ }
10
+ const field = line.split(':')[9];
11
+ const match = field.match(/<([^<>]+)>\s*$/);
12
+ if (!match) {
13
+ return null;
14
+ }
15
+ return { name: field.slice(0, match.index).trim(), email: match[1].trim() };
16
+ }
17
+ function processResult(stdout) {
18
+ const keys = [];
19
+ let current = undefined;
20
+ for (const line of stdout.split('\n')) {
21
+ if (line.startsWith('sec:')) {
22
+ if (current && current.uids.length > 0) {
23
+ keys.push(current);
24
+ }
25
+ current = { keyId: line.split(':')[4], uids: [] };
26
+ }
27
+ else if (line.startsWith('uid:') && current) {
28
+ const uid = parseUid(line);
29
+ if (uid) {
30
+ current.uids.push(uid);
31
+ }
32
+ }
33
+ }
34
+ if (current && current.uids.length > 0) {
35
+ keys.push(current);
36
+ }
37
+ return keys;
38
+ }
39
+ function matchesQuery(key, query) {
40
+ const q = query.toLowerCase();
41
+ if (key.keyId.toLowerCase() === q) {
42
+ return true;
43
+ }
44
+ return key.uids.some((uid) => uid.email.toLowerCase() === q);
45
+ }
46
+ /**
47
+ * List the current GPG Keys on the system that match the query.
48
+ */
49
+ export async function listGpgKeys(query) {
50
+ const args = ['--list-secret-keys', '--with-colons', '--keyid-format=long'];
51
+ try {
52
+ const result = await execFileAsync('gpg', args, { encoding: 'utf8' });
53
+ const keys = processResult(result.stdout);
54
+ return query ? keys.filter((key) => matchesQuery(key, query)) : keys;
55
+ }
56
+ catch (error) {
57
+ if (error.code === 'ENOENT') {
58
+ throw new Error('gpg is required but was not found on PATH. Please install.', { cause: error });
59
+ }
60
+ throw error;
61
+ }
62
+ }
@@ -0,0 +1,22 @@
1
+ import chalk from 'chalk';
2
+ const log = {
3
+ /**
4
+ * Writes a red-colored message to stderr, surrounded by blank lines.
5
+ */
6
+ error(msg) {
7
+ console.error(`\n${chalk.red(msg)}\n`);
8
+ },
9
+ /**
10
+ * Writes a yellow-colored message to stderr, surrounded by blank lines.
11
+ */
12
+ warn(msg) {
13
+ console.error(`\n${chalk.yellow(msg)}\n`);
14
+ },
15
+ /**
16
+ * Writes a message to stdout, surrounded by blank lines.
17
+ */
18
+ info(msg) {
19
+ console.log(`\n${msg}\n`);
20
+ },
21
+ };
22
+ export default log;