@bstopp/git-who 0.1.0 → 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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bstopp/git-who",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Manage git profiles for per-project SSH account configuration.",
6
6
  "license": "Apache-2.0",
package/dist/src/cli.js CHANGED
@@ -19,14 +19,18 @@ program
19
19
  program
20
20
  .command('show [name]')
21
21
  .description('Show the current git profile for this repo')
22
+ .option('--json', 'Print the result as JSON')
22
23
  .action(show);
23
24
  program
24
25
  .command('list')
25
26
  .description('List all available profiles')
27
+ .option('--json', 'Print the result as JSON')
26
28
  .action(list);
27
29
  program
28
30
  .command('use [name]')
29
31
  .description('Set the profile for this repo.')
32
+ .option('--json', 'Print the result as JSON, without prompting (a profile name is required)')
33
+ .option('--update-origin', 'Update the origin remote to match the profile, without asking')
30
34
  .action(use);
31
35
  program
32
36
  .command('add')
@@ -45,16 +49,19 @@ program
45
49
  .description('Clone a remote repo using a specific profile.')
46
50
  .action(clone);
47
51
  /**
48
- * Join an error's message with those of its `cause` chain, one per line, so wrapped failures show the real reason.
52
+ * The message of an error followed by those of its `cause` chain, so wrapped failures show the real reason.
49
53
  */
50
- function describeError(err) {
51
- const lines = [];
54
+ function errorMessages(err) {
55
+ const messages = [];
52
56
  let current = err;
53
57
  while (current instanceof Error) {
54
- lines.push(lines.length === 0 ? current.message : `Caused by: ${current.message}`);
58
+ messages.push(current.message);
55
59
  current = current.cause;
56
60
  }
57
- return lines.length > 0 ? lines.join('\n') : String(err);
61
+ return messages.length > 0 ? messages : [String(err)];
62
+ }
63
+ function describeError(err) {
64
+ return errorMessages(err).map((message, i) => i === 0 ? message : `Caused by: ${message}`).join('\n');
58
65
  }
59
66
  /**
60
67
  * Run the cli, printing any failure (with its cause chain) and setting a non-zero exit code. A cancelled prompt
@@ -69,7 +76,13 @@ export async function run(argv) {
69
76
  process.exitCode = SIGINT_EXIT_CODE;
70
77
  return;
71
78
  }
72
- log.error(describeError(err));
79
+ if (argv.includes('--json')) {
80
+ const [error, ...causes] = errorMessages(err);
81
+ log.json({ error, causes });
82
+ }
83
+ else {
84
+ log.error(describeError(err));
85
+ }
73
86
  process.exitCode = 1;
74
87
  }
75
88
  }
@@ -2,9 +2,13 @@ import chalk from 'chalk';
2
2
  import { simpleGit } from 'simple-git';
3
3
  import log from "../utils/log.js";
4
4
  import { loadProfiles, getActiveProfileName } from "../utils/profiles.js";
5
- export default async function list() {
5
+ export default async function list(options = {}) {
6
6
  const profiles = await loadProfiles();
7
7
  const activeName = await getActiveProfileName(simpleGit());
8
+ if (options.json) {
9
+ log.json(profiles.map((p) => ({ ...p, active: p.name === activeName })));
10
+ return;
11
+ }
8
12
  const lines = profiles.map((p) => {
9
13
  if (p.name === activeName) {
10
14
  return chalk.bold(` - ${p.name} (${p.host})`);
@@ -128,14 +128,43 @@ async function resolveProfile(name, profiles) {
128
128
  return await resolveFromGit(profiles);
129
129
  }
130
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
+ }
131
159
  /**
132
160
  * 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.
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.
134
163
  *
135
164
  * @throws {Error} If no name is given outside a git repo, the named profile does not exist, or no profile can be
136
165
  * resolved from the repo. Errors from the ssh and gpg key lookups are not caught.
137
166
  */
138
- export default async function show(name) {
167
+ export default async function show(name, options = {}) {
139
168
  const git = simpleGit();
140
169
  if (!name && !(await git.checkIsRepo())) {
141
170
  throw new Error('You must provide a profile name, or invoke the show command from a git repo.');
@@ -143,16 +172,11 @@ export default async function show(name) {
143
172
  const profiles = await loadProfiles();
144
173
  const resolved = await resolveProfile(name, profiles);
145
174
  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;
175
+ if (options.json) {
176
+ log.json(toJson(resolved, signingKeyStatus));
177
+ return;
154
178
  }
155
- printProfile(resolved.profile, opts);
179
+ printProfile(resolved.profile, displayOptions(resolved, signingKeyStatus));
156
180
  if (resolved.derived) {
157
181
  log.warn(`Profile derived from git config — not explicitly set via gitwho.profile`);
158
182
  }
@@ -11,54 +11,74 @@ function extractRepoPath(url) {
11
11
  return parseSshUrl(url)?.path ?? url.match(HTTPS_URL_PATTERN)?.[1];
12
12
  }
13
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>`.
14
+ * Find where the origin remote differs from the url the profile's ssh host would give it, `git@<host>:<path>`.
16
15
  *
17
- * @returns The url to set, or `undefined` to leave the origin alone (no origin, already matching, or declined).
16
+ * @returns The current and expected urls, or `undefined` when there is no origin or it already matches.
18
17
  * @throws {Error} If the origin is neither an ssh nor an http(s) url, so its repo path cannot be determined.
19
18
  */
20
- async function resolveOriginUrl(git, profile) {
21
- const { value: currentUrl } = await git.getConfig('remote.origin.url', 'local');
22
- if (!currentUrl) {
19
+ async function checkOrigin(git, profile) {
20
+ const { value: current } = await git.getConfig('remote.origin.url', 'local');
21
+ if (!current) {
23
22
  return undefined;
24
23
  }
25
- const path = extractRepoPath(currentUrl);
24
+ const path = extractRepoPath(current);
26
25
  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;
26
+ throw new Error(`Unable to determine the repo path from origin remote '${current}'.`);
32
27
  }
28
+ const expected = `git@${profile.host}:${path}`;
29
+ return expected === current ? undefined : { current, expected };
30
+ }
31
+ function confirmOriginUpdate(profile, { current, expected }) {
33
32
  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?`,
33
+ return confirm({
34
+ message: `Origin remote is ${style.highlight(current)}, but profile '${profile.name}' expects ${style.highlight(expected)}. Update origin to match?`,
36
35
  default: true,
37
36
  });
38
- return updateOrigin ? desiredUrl : undefined;
39
37
  }
40
38
  /**
41
- * Apply a profile to the current repo, either the one given by name or one picked from a prompt.
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.
42
55
  *
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.
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.
47
60
  */
48
- export default async function use(name) {
61
+ export default async function use(name, options = {}) {
49
62
  const git = simpleGit();
50
63
  if (!await git.checkIsRepo()) {
51
64
  throw new Error('The current directory must have a git repository to use a profile.');
52
65
  }
53
66
  const profiles = await loadProfiles();
54
- const profile = await selectProfile(profiles, name);
67
+ const profile = options.json ? findProfile(profiles, name) : await selectProfile(profiles, name);
55
68
  await verifySigningKey(profile);
56
- const originUrl = await resolveOriginUrl(git, profile);
69
+ const origin = await checkOrigin(git, profile);
70
+ const updateOrigin = origin !== undefined
71
+ && (options.updateOrigin || (!options.json && await confirmOriginUpdate(profile, origin)));
57
72
  try {
58
- await applyProfile(git, profile, originUrl);
73
+ await applyProfile(git, profile, updateOrigin ? origin.expected : undefined);
59
74
  }
60
75
  catch (error) {
61
76
  throw new Error(`Failed to apply profile '${profile.name}'. Git config may be partly updated; re-run to retry.`, { cause: error });
62
77
  }
63
- log.info(`Now using profile '${chalk.bold(profile.name)}'.`);
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
+ }
64
84
  }
@@ -18,5 +18,11 @@ const log = {
18
18
  info(msg) {
19
19
  console.log(`\n${msg}\n`);
20
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
+ },
21
27
  };
22
28
  export default log;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bstopp/git-who",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Manage git profiles for per-project SSH account configuration.",
6
6
  "license": "Apache-2.0",