Skip to content

Commit f9abec1

Browse files
authored
feat(auth): add aggregate status command (#1879)
1 parent 77b29b3 commit f9abec1

7 files changed

Lines changed: 444 additions & 0 deletions

File tree

clis/_shared/site-auth.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ function commandColumns(config) {
2929
return ['logged_in', 'site', ...identityColumns];
3030
}
3131

32+
function normalizeQuickCheck(result) {
33+
if (typeof result === 'boolean') return { logged_in: result };
34+
if (result && typeof result === 'object' && !Array.isArray(result)) {
35+
return { logged_in: !!result.logged_in, ...result };
36+
}
37+
return { logged_in: false };
38+
}
39+
3240
export function registerSiteAuthCommands(config) {
3341
if (!config?.site || !config?.domain || !config?.loginUrl || typeof config.verify !== 'function') {
3442
throw new Error('registerSiteAuthCommands requires site, domain, loginUrl, and verify(page)');
@@ -45,6 +53,9 @@ export function registerSiteAuthCommands(config) {
4553
navigateBefore: false,
4654
args: [],
4755
columns: commandColumns(config),
56+
authStatus: typeof config.quickCheck === 'function'
57+
? { quickCheck: async (page) => normalizeQuickCheck(await config.quickCheck(page)) }
58+
: undefined,
4859
func: async (page) => tryProbe(config, page, 'identity'),
4960
});
5061

src/cli.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,47 @@ describe('createProgram root help descriptions', () => {
6666
expect(descriptionFor(program, 'browser')).toContain('type');
6767
expect(descriptionFor(program, 'browser')).toContain('verify');
6868
expect(descriptionFor(program, 'browser')).not.toContain('Browser control');
69+
expect(descriptionFor(program, 'auth')).toBe('status');
6970
expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
7071
expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
7172
expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
7273
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
7374
expect(descriptionFor(program, 'external')).toBe('install, list, register');
7475
});
7576

77+
it('renders auth namespace structured help', () => {
78+
const argv = process.argv;
79+
try {
80+
const program = createProgram('', '');
81+
const auth = program.commands.find(cmd => cmd.name() === 'auth')!;
82+
expect(auth).toBeTruthy();
83+
84+
process.argv = ['node', 'opencli', 'auth', '--help', '-f', 'yaml'];
85+
const data = yaml.load(auth.helpInformation()) as any;
86+
87+
expect(data).toMatchObject({
88+
namespace: 'auth',
89+
description: 'Inspect website login status',
90+
command_count: 1,
91+
});
92+
expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['status']);
93+
const status = auth.commands.find(cmd => cmd.name() === 'status')!;
94+
process.argv = ['node', 'opencli', 'auth', 'status', '--help', '-f', 'yaml'];
95+
const statusData = yaml.load(status.helpInformation()) as any;
96+
expect(statusData.command).toBe('opencli auth status');
97+
expect(statusData.command_options.map((option: any) => option.name)).toEqual(expect.arrayContaining([
98+
'site',
99+
'full',
100+
'concurrency',
101+
'timeout',
102+
'only',
103+
'format',
104+
]));
105+
} finally {
106+
process.argv = argv;
107+
}
108+
});
109+
76110
it('keeps leaf command descriptions unchanged', () => {
77111
const program = createProgram('', '');
78112

src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js';
3030
import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js';
3131
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
3232
import { analyzeSite, type PageSignals } from './browser/analyze.js';
33+
import { registerAuthCommands } from './commands/auth.js';
3334
import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
3435
import { log } from './logger.js';
3536
import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
@@ -796,6 +797,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
796797
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
797798
});
798799

800+
const authCmd = registerAuthCommands(program);
801+
799802
program
800803
.command('convention-audit')
801804
.description('Scan adapters for agent-native convention violations')
@@ -3470,6 +3473,7 @@ cli({
34703473
const adapterGroups: RootAdapterGroups = { external: externalHelpEntries, apps, sites };
34713474
const adapterNameSet = new Set<string>([...externalNames, ...siteNames]);
34723475
installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
3476+
installCommanderNamespaceStructuredHelp(authCmd, { globalCommand: program, description: 'Inspect website login status' });
34733477
installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
34743478
installCommanderNamespaceStructuredHelp(pluginCmd, { globalCommand: program, description: originalPluginDescription });
34753479
installCommanderNamespaceStructuredHelp(adapterCmd, { globalCommand: program, description: originalAdapterDescription });

src/commands/auth.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { BrowserCliCommand } from '../registry.js';
3+
4+
const executeCommandMock = vi.hoisted(() => vi.fn());
5+
6+
vi.mock('../execution.js', () => ({
7+
executeCommand: executeCommandMock,
8+
}));
9+
10+
import { collectAuthStatus } from './auth.js';
11+
import { AuthRequiredError } from '../errors.js';
12+
import { cli, getRegistry, Strategy } from '../registry.js';
13+
14+
function registerWhoami(site: string, opts: {
15+
quick?: boolean;
16+
quickLoggedIn?: boolean;
17+
identity?: Record<string, unknown>;
18+
} = {}): void {
19+
cli({
20+
site,
21+
name: 'whoami',
22+
access: 'read',
23+
description: `${site} whoami`,
24+
strategy: Strategy.COOKIE,
25+
browser: true,
26+
domain: `${site}.example.com`,
27+
navigateBefore: false,
28+
args: [],
29+
columns: ['logged_in', 'site', 'username'],
30+
authStatus: opts.quick
31+
? { quickCheck: async () => ({ logged_in: opts.quickLoggedIn ?? false }) }
32+
: undefined,
33+
func: async () => opts.identity ?? { logged_in: true, site, username: site },
34+
});
35+
}
36+
37+
beforeEach(() => {
38+
getRegistry().clear();
39+
executeCommandMock.mockReset();
40+
executeCommandMock.mockImplementation(async (cmd: BrowserCliCommand, kwargs: Record<string, unknown>) => {
41+
if (!cmd.func) return {};
42+
return cmd.func({} as never, kwargs);
43+
});
44+
});
45+
46+
describe('auth status collection', () => {
47+
it('uses quickCheck by default and does not run full whoami', async () => {
48+
registerWhoami('alpha', { quick: true, quickLoggedIn: true, identity: { username: 'full-alpha' } });
49+
50+
const rows = await collectAuthStatus({ sites: 'alpha' });
51+
52+
expect(rows).toEqual([
53+
{ site: 'alpha', status: 'logged_in', logged_in: true, identity: '', checked: 'quick', error: '' },
54+
]);
55+
expect(executeCommandMock).toHaveBeenCalledTimes(1);
56+
expect(executeCommandMock.mock.calls[0]?.[0]).toMatchObject({
57+
site: 'alpha',
58+
name: 'whoami',
59+
navigateBefore: false,
60+
siteSession: 'ephemeral',
61+
defaultWindowMode: 'background',
62+
});
63+
});
64+
65+
it('marks sites without quickCheck as unknown unless --full is used', async () => {
66+
registerWhoami('beta');
67+
68+
const rows = await collectAuthStatus({ sites: 'beta' });
69+
70+
expect(rows).toEqual([
71+
{
72+
site: 'beta',
73+
status: 'unknown',
74+
logged_in: '',
75+
identity: '',
76+
checked: 'skipped',
77+
error: 'quickCheck not implemented; use --full to run whoami',
78+
},
79+
]);
80+
expect(executeCommandMock).not.toHaveBeenCalled();
81+
});
82+
83+
it('runs full whoami with --full and returns a safe identity summary', async () => {
84+
registerWhoami('gamma', {
85+
identity: {
86+
logged_in: true,
87+
site: 'gamma',
88+
email: 'hidden@example.com',
89+
username: 'public-handle',
90+
},
91+
});
92+
93+
const rows = await collectAuthStatus({ sites: 'gamma', full: true });
94+
95+
expect(rows).toEqual([
96+
{ site: 'gamma', status: 'logged_in', logged_in: true, identity: 'public-handle', checked: 'full', error: '' },
97+
]);
98+
});
99+
100+
it('converts AuthRequiredError into not_logged_in rows', async () => {
101+
registerWhoami('delta', { quick: true });
102+
executeCommandMock.mockRejectedValueOnce(new AuthRequiredError('delta.example.com'));
103+
104+
const rows = await collectAuthStatus({ sites: 'delta' });
105+
106+
expect(rows).toEqual([
107+
{ site: 'delta', status: 'not_logged_in', logged_in: false, identity: '', checked: 'quick', error: '' },
108+
]);
109+
});
110+
});

0 commit comments

Comments
 (0)