@bstopp/git-who 0.0.0-stage → 0.2.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,20 @@
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(options = {}) {
6
+ const profiles = await loadProfiles();
7
+ const activeName = await getActiveProfileName(simpleGit());
8
+ if (options.json) {
9
+ log.json(profiles.map((p) => ({ ...p, active: p.name === activeName })));
10
+ return;
11
+ }
12
+ const lines = profiles.map((p) => {
13
+ if (p.name === activeName) {
14
+ return chalk.bold(` - ${p.name} (${p.host})`);
15
+ }
16
+ return ` - ${p.name} (${p.host})`;
17
+ });
18
+ lines.unshift('Known profiles:');
19
+ log.info(lines.join('\n'));
20
+ }
@@ -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,183 @@
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
+ * The result as a plain object for `--json`. The warnings that are printed as text become fields, so nothing is lost
133
+ * from stdout. A mismatched field git has no value for is `null`, which JSON would otherwise drop from the object.
134
+ */
135
+ function toJson(resolved, signingKeyStatus) {
136
+ const { profile, derived, mismatches } = resolved;
137
+ return {
138
+ profile,
139
+ derived,
140
+ signingKeyStatus,
141
+ mismatches: mismatches && Object.fromEntries(Object.entries(mismatches).map(([field, value]) => [field, value ?? null])),
142
+ };
143
+ }
144
+ /**
145
+ * The extras for `printProfile`, or `undefined` when there are none, so a plain profile is printed without them.
146
+ */
147
+ function displayOptions(resolved, signingKeyStatus) {
148
+ let opts = undefined;
149
+ if (resolved.mismatches && Object.keys(resolved.mismatches).length > 0) {
150
+ opts ||= {};
151
+ opts.mismatches = resolved.mismatches;
152
+ }
153
+ if (signingKeyStatus) {
154
+ opts ||= {};
155
+ opts.signingKeyStatus = signingKeyStatus;
156
+ }
157
+ return opts;
158
+ }
159
+ /**
160
+ * Print a profile, either the one given by name or the one resolved from the current repo. A profile resolved from the
161
+ * repo is also checked against its git config, and any signing key is looked up to confirm it exists. With `json` the
162
+ * result is printed as JSON instead.
163
+ *
164
+ * @throws {Error} If no name is given outside a git repo, the named profile does not exist, or no profile can be
165
+ * resolved from the repo. Errors from the ssh and gpg key lookups are not caught.
166
+ */
167
+ export default async function show(name, options = {}) {
168
+ const git = simpleGit();
169
+ if (!name && !(await git.checkIsRepo())) {
170
+ throw new Error('You must provide a profile name, or invoke the show command from a git repo.');
171
+ }
172
+ const profiles = await loadProfiles();
173
+ const resolved = await resolveProfile(name, profiles);
174
+ const signingKeyStatus = await buildStatus(resolved);
175
+ if (options.json) {
176
+ log.json(toJson(resolved, signingKeyStatus));
177
+ return;
178
+ }
179
+ printProfile(resolved.profile, displayOptions(resolved, signingKeyStatus));
180
+ if (resolved.derived) {
181
+ log.warn(`Profile derived from git config — not explicitly set via gitwho.profile`);
182
+ }
183
+ }
@@ -0,0 +1,84 @@
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
+ * Find where the origin remote differs from the url the profile's ssh host would give it, `git@<host>:<path>`.
15
+ *
16
+ * @returns The current and expected urls, or `undefined` when there is no origin or it already matches.
17
+ * @throws {Error} If the origin is neither an ssh nor an http(s) url, so its repo path cannot be determined.
18
+ */
19
+ async function checkOrigin(git, profile) {
20
+ const { value: current } = await git.getConfig('remote.origin.url', 'local');
21
+ if (!current) {
22
+ return undefined;
23
+ }
24
+ const path = extractRepoPath(current);
25
+ if (!path) {
26
+ throw new Error(`Unable to determine the repo path from origin remote '${current}'.`);
27
+ }
28
+ const expected = `git@${profile.host}:${path}`;
29
+ return expected === current ? undefined : { current, expected };
30
+ }
31
+ function confirmOriginUpdate(profile, { current, expected }) {
32
+ const { style } = makeTheme();
33
+ return confirm({
34
+ message: `Origin remote is ${style.highlight(current)}, but profile '${profile.name}' expects ${style.highlight(expected)}. Update origin to match?`,
35
+ default: true,
36
+ });
37
+ }
38
+ /**
39
+ * Look a profile up by name without falling back to a prompt, for output that nobody is there to answer.
40
+ */
41
+ function findProfile(profiles, name) {
42
+ if (!name) {
43
+ throw new Error('A profile name is required with --json.');
44
+ }
45
+ const profile = profiles.find((p) => p.name === name);
46
+ if (!profile) {
47
+ throw new Error(`Profile ${name} not found.`);
48
+ }
49
+ return profile;
50
+ }
51
+ /**
52
+ * Apply a profile to the current repo, either the one given by name or one picked from a prompt. A mismatched origin is
53
+ * rewritten when `updateOrigin` is set, otherwise the user is asked, except with `json`, which never prompts: it needs
54
+ * a name and leaves the origin alone unless `updateOrigin` is set.
55
+ *
56
+ * @throws {Error} If the current directory is not a git repo, `json` has no name or the name is unknown, the profile's
57
+ * signing key is missing or cannot be looked up, the origin remote url cannot be parsed, or applying the profile fails.
58
+ * The signing key is checked first and its failure is thrown as is, because nothing has been written by then. A failure
59
+ * to apply is wrapped with its `cause`, since the git config may be left partly updated.
60
+ */
61
+ export default async function use(name, options = {}) {
62
+ const git = simpleGit();
63
+ if (!await git.checkIsRepo()) {
64
+ throw new Error('The current directory must have a git repository to use a profile.');
65
+ }
66
+ const profiles = await loadProfiles();
67
+ const profile = options.json ? findProfile(profiles, name) : await selectProfile(profiles, name);
68
+ await verifySigningKey(profile);
69
+ const origin = await checkOrigin(git, profile);
70
+ const updateOrigin = origin !== undefined
71
+ && (options.updateOrigin || (!options.json && await confirmOriginUpdate(profile, origin)));
72
+ try {
73
+ await applyProfile(git, profile, updateOrigin ? origin.expected : undefined);
74
+ }
75
+ catch (error) {
76
+ throw new Error(`Failed to apply profile '${profile.name}'. Git config may be partly updated; re-run to retry.`, { cause: error });
77
+ }
78
+ if (options.json) {
79
+ log.json({ profile, originUpdated: updateOrigin, origin });
80
+ }
81
+ else {
82
+ log.info(`Now using profile '${chalk.bold(profile.name)}'.`);
83
+ }
84
+ }
@@ -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,28 @@
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
+ * Writes a value to stdout as indented JSON, with no blank lines or colors so the output can be parsed as is.
23
+ */
24
+ json(value) {
25
+ console.log(JSON.stringify(value, null, 2));
26
+ },
27
+ };
28
+ export default log;