Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('auth status collection', () => {
const rows = await collectAuthStatus({ sites: 'alpha' });

expect(rows).toEqual([
{ site: 'alpha', status: 'logged_in', logged_in: true, identity: '', checked: 'quick', error: '' },
{ site: 'alpha', status: 'logged_in', recovery: '', logged_in: true, identity: '', checked: 'quick', error: '' },
]);
expect(executeCommandMock).toHaveBeenCalledTimes(1);
expect(executeCommandMock.mock.calls[0]?.[0]).toMatchObject({
Expand All @@ -81,6 +81,7 @@ describe('auth status collection', () => {
{
site: 'beta',
status: 'unknown',
recovery: '',
logged_in: '',
identity: '',
checked: 'skipped',
Expand All @@ -103,7 +104,7 @@ describe('auth status collection', () => {
const rows = await collectAuthStatus({ sites: 'gamma', full: true });

expect(rows).toEqual([
{ site: 'gamma', status: 'logged_in', logged_in: true, identity: 'public-handle', checked: 'full', error: '' },
{ site: 'gamma', status: 'logged_in', recovery: '', logged_in: true, identity: 'public-handle', checked: 'full', error: '' },
]);
});

Expand All @@ -114,7 +115,7 @@ describe('auth status collection', () => {
const rows = await collectAuthStatus({ sites: 'delta' });

expect(rows).toEqual([
{ site: 'delta', status: 'not_logged_in', logged_in: false, identity: '', checked: 'quick', error: '' },
{ site: 'delta', status: 'not_logged_in', recovery: 'opencli delta login', logged_in: false, identity: '', checked: 'quick', error: '' },
]);
});
});
Expand All @@ -131,6 +132,7 @@ describe('auth refresh collection', () => {
{
site: 'alpha',
status: 'touched',
recovery: '',
last_touched_at: now.toISOString(),
next_refresh_at: '2026-06-07T12:00:00.000Z',
error: '',
Expand Down Expand Up @@ -191,6 +193,7 @@ describe('auth refresh collection', () => {
{
site: 'gamma',
status: 'skipped',
recovery: '',
last_touched_at: '2026-06-06T12:00:00.000Z',
next_refresh_at: '2026-06-07T12:00:00.000Z',
error: '',
Expand Down Expand Up @@ -224,7 +227,7 @@ describe('auth refresh collection', () => {
it('does not throttle not_logged_in results', async () => {
registerWhoami('epsilon', { quick: true, quickLoggedIn: true });
const statePath = await tempStatePath();
executeCommandMock.mockRejectedValueOnce(new AuthRequiredError('epsilon.example.com'));
executeCommandMock.mockRejectedValueOnce(new AuthRequiredError('epsilon.example.com', 'session expired for account user@example.com'));

const rows = await collectAuthRefresh({
sites: 'epsilon',
Expand All @@ -233,8 +236,10 @@ describe('auth refresh collection', () => {
});

expect(rows).toEqual([
{ site: 'epsilon', status: 'not_logged_in', last_touched_at: '', next_refresh_at: '', error: '' },
{ site: 'epsilon', status: 'not_logged_in', recovery: 'opencli epsilon login', last_touched_at: '', next_refresh_at: '', error: '' },
]);
expect(rows[0]?.recovery).not.toContain('epsilon.example.com');
expect(rows[0]?.recovery).not.toContain('user@example.com');
const state = JSON.parse(await readFile(statePath, 'utf8'));
expect(state.sites.epsilon).toMatchObject({
last_attempt_at: '2026-06-06T12:00:00.000Z',
Expand Down Expand Up @@ -263,7 +268,7 @@ describe('auth refresh collection', () => {
});

expect(rows).toEqual([
{ site: 'zeta', status: 'error', last_touched_at: '', next_refresh_at: '', error: 'network down' },
{ site: 'zeta', status: 'error', recovery: '', last_touched_at: '', next_refresh_at: '', error: 'network down' },
]);
const state = JSON.parse(await readFile(statePath, 'utf8'));
expect(state.sites.zeta).toMatchObject({
Expand All @@ -287,6 +292,7 @@ describe('auth refresh collection', () => {
{
site: 'eta',
status: 'unsupported',
recovery: '',
last_touched_at: '',
next_refresh_at: '',
error: 'refresh probe is not available for this site',
Expand Down
41 changes: 36 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const AUTH_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
export interface AuthStatusRow {
site: string;
status: AuthStatus;
recovery: string;
logged_in: boolean | '';
identity: string;
checked: AuthStatusMode | 'skipped';
Expand All @@ -43,6 +44,7 @@ interface AuthStatusOptions {
export interface AuthRefreshRow {
site: string;
status: AuthRefreshStatus;
recovery: string;
last_touched_at: string;
next_refresh_at: string;
error: string;
Expand Down Expand Up @@ -125,6 +127,10 @@ function nextRefreshAt(entry: AuthRefreshSiteState | undefined): string {
return touched === null ? '' : new Date(touched + AUTH_REFRESH_INTERVAL_MS).toISOString();
}

function authRecoveryCommand(site: string): string {
return `opencli ${site} login`;
}

function authWhoamiCommands(): CliCommand[] {
const seen = new Set<CliCommand>();
return [...getRegistry().values()]
Expand Down Expand Up @@ -198,13 +204,22 @@ function identitySummary(result: unknown): string {

function rowForError(site: string, checked: AuthStatusMode, error: unknown): AuthStatusRow {
if (error instanceof AuthRequiredError) {
return { site, status: 'not_logged_in', logged_in: false, identity: '', checked, error: '' };
return {
site,
status: 'not_logged_in',
recovery: authRecoveryCommand(site),
logged_in: false,
identity: '',
checked,
error: '',
};
}
const code = error instanceof CliError ? error.code : '';
const message = getErrorMessage(error);
return {
site,
status: 'error',
recovery: '',
logged_in: '',
identity: '',
checked,
Expand Down Expand Up @@ -253,6 +268,7 @@ function refreshRowForError(site: string, entry: AuthRefreshSiteState | undefine
return {
site,
status: 'not_logged_in',
recovery: authRecoveryCommand(site),
last_touched_at: entry?.last_touched_at ?? '',
next_refresh_at: nextRefreshAt(entry),
error: '',
Expand All @@ -263,6 +279,7 @@ function refreshRowForError(site: string, entry: AuthRefreshSiteState | undefine
return {
site,
status: 'error',
recovery: '',
last_touched_at: entry?.last_touched_at ?? '',
next_refresh_at: nextRefreshAt(entry),
error: code ? `${code}: ${message}` : message,
Expand All @@ -276,6 +293,7 @@ async function runQuick(cmd: CliCommand, opts: { timeoutSeconds: number; profile
return {
site: cmd.site,
status: 'unknown',
recovery: '',
logged_in: '',
identity: '',
checked: 'skipped',
Expand All @@ -292,14 +310,23 @@ async function runQuick(cmd: CliCommand, opts: { timeoutSeconds: number; profile
});
const loggedIn = normalizeQuickResult(result);
if (loggedIn === true) {
return { site: cmd.site, status: 'logged_in', logged_in: true, identity: '', checked: 'quick', error: '' };
return { site: cmd.site, status: 'logged_in', recovery: '', logged_in: true, identity: '', checked: 'quick', error: '' };
}
if (loggedIn === false) {
return { site: cmd.site, status: 'not_logged_in', logged_in: false, identity: '', checked: 'quick', error: '' };
return {
site: cmd.site,
status: 'not_logged_in',
recovery: authRecoveryCommand(cmd.site),
logged_in: false,
identity: '',
checked: 'quick',
error: '',
};
}
return {
site: cmd.site,
status: 'unknown',
recovery: '',
logged_in: '',
identity: '',
checked: 'quick',
Expand All @@ -323,6 +350,7 @@ async function runFull(cmd: CliCommand, opts: { timeoutSeconds: number; profile?
return {
site: cmd.site,
status: 'logged_in',
recovery: '',
logged_in: true,
identity: identitySummary(result),
checked: 'full',
Expand All @@ -345,6 +373,7 @@ async function runRefresh(cmd: CliCommand, opts: {
return {
site: cmd.site,
status: 'skipped',
recovery: '',
last_touched_at: existing?.last_touched_at ?? '',
next_refresh_at: nextRefreshAt(existing),
error: '',
Expand All @@ -359,6 +388,7 @@ async function runRefresh(cmd: CliCommand, opts: {
return {
site: cmd.site,
status: 'unsupported',
recovery: '',
last_touched_at: existing?.last_touched_at ?? '',
next_refresh_at: nextRefreshAt(existing),
error: 'refresh probe is not available for this site',
Expand All @@ -382,6 +412,7 @@ async function runRefresh(cmd: CliCommand, opts: {
return {
site: cmd.site,
status,
recovery: '',
last_touched_at: attemptAt,
next_refresh_at: new Date(opts.now.getTime() + AUTH_REFRESH_INTERVAL_MS).toISOString(),
error: '',
Expand Down Expand Up @@ -481,7 +512,7 @@ export function registerAuthCommands(program: Command): Command {
renderOutput(rows, {
fmt,
fmtExplicit: status.getOptionValueSource('format') === 'cli',
columns: ['site', 'status', 'identity', 'checked', 'error'],
columns: ['site', 'status', 'recovery', 'identity', 'checked', 'error'],
title: 'opencli/auth status',
source: opts.full ? 'full whoami probe' : 'quick auth check',
});
Expand All @@ -508,7 +539,7 @@ export function registerAuthCommands(program: Command): Command {
renderOutput(rows, {
fmt,
fmtExplicit: refresh.getOptionValueSource('format') === 'cli',
columns: ['site', 'status', 'last_touched_at', 'next_refresh_at', 'error'],
columns: ['site', 'status', 'recovery', 'last_touched_at', 'next_refresh_at', 'error'],
title: 'opencli/auth refresh',
source: opts.all ? 'forced persistent touch' : 'persistent touch with 24h throttle',
});
Expand Down