diff --git a/src/auth/__tests__/logout.test.ts b/src/auth/__tests__/logout.test.ts index 33d2a09..a2a4c5e 100644 --- a/src/auth/__tests__/logout.test.ts +++ b/src/auth/__tests__/logout.test.ts @@ -25,10 +25,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -async function runLogout(): Promise { +async function runLogout(args: string[] = []): Promise { const program = new Command(); + // Mirror the root program's global --json flag so the action can read it. + program.option('--json', 'machine-readable output'); logoutCommand(program); - await program.parseAsync(['node', 'hookmyapp', 'logout']); + await program.parseAsync(['node', 'hookmyapp', 'logout', ...args]); } describe('logout', () => { @@ -48,4 +50,68 @@ describe('logout', () => { await expect(runLogout()).resolves.toBeUndefined(); expect(logSpy.mock.calls.flat().join('')).toMatch(/Logged out/); }); + + test('--json emits JSON, not the human check line (AIT-164)', async () => { + const credsPath = join(DIR, 'credentials.json'); + writeFileSync(credsPath, JSON.stringify({ accessToken: 'a', refreshToken: 'r', expiresAt: 1 })); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runLogout(['--json']); + + expect(existsSync(credsPath)).toBe(false); + const written = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(JSON.parse(written.trim())).toEqual({ status: 'logged_out', revoked: false }); + // The human check line must NOT be printed in --json mode. + expect(logSpy.mock.calls.flat().join('')).not.toMatch(/Logged out/); + }); + + test('agent credential → self-revokes server-side before clearing local creds (AIT-153)', async () => { + const credsPath = join(DIR, 'credentials.json'); + writeFileSync( + credsPath, + JSON.stringify({ + accessToken: 'ac_token', + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: 'ac_pub_1234', + }), + ); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await runLogout(); + + // Called DELETE on the self-revoke endpoint with the stored publicId. + const call = fetchMock.mock.calls.find(([url]) => + String(url).includes('/agent/credentials/ac_pub_1234'), + ); + expect(call).toBeDefined(); + expect(call![1]).toMatchObject({ method: 'DELETE' }); + expect(existsSync(credsPath)).toBe(false); + vi.unstubAllGlobals(); + }); + + test('revoke failure still clears local credentials (AIT-153)', async () => { + const credsPath = join(DIR, 'credentials.json'); + writeFileSync( + credsPath, + JSON.stringify({ + accessToken: 'ac_token', + refreshToken: '', + expiresAt: 0, + kind: 'agent', + credentialPublicId: 'ac_pub_9999', + }), + ); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + + await expect(runLogout()).resolves.toBeUndefined(); + + expect(existsSync(credsPath)).toBe(false); + expect(logSpy.mock.calls.flat().join('')).toMatch(/Logged out/); + vi.unstubAllGlobals(); + }); }); diff --git a/src/auth/logout.ts b/src/auth/logout.ts index 0c4c14b..eab2f68 100644 --- a/src/auth/logout.ts +++ b/src/auth/logout.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; -import { deleteCredentials } from './store.js'; +import { readCredentials, deleteCredentials } from './store.js'; +import { isAgentCredential } from '../storage/secrets.js'; import { addExamples } from '../output/help.js'; export function logoutCommand(program: Command): void { @@ -7,8 +8,36 @@ export function logoutCommand(program: Command): void { .command('logout') .description('Remove stored credentials') .action(async () => { + const json = !!program.opts().json; + + // AIT-153: for an agent credential (an `ac_`/API key), also revoke it + // server-side so it can't keep being used after logout. Best-effort — an + // offline host (or an already-revoked key) must still clear local + // credentials. WorkOS sessions carry no CLI-side revoke, so this only + // fires for agent credentials. + let revoked = false; + const creds = await readCredentials(); + if (creds && isAgentCredential(creds) && creds.credentialPublicId) { + try { + const { apiClient } = await import('../api/client.js'); + await apiClient(`/agent/credentials/${creds.credentialPublicId}`, { + method: 'DELETE', + }); + revoked = true; + } catch { + // Offline / already revoked — proceed to clear local credentials. + } + } + await deleteCredentials(); - console.log('\n✓ Logged out\n'); + + if (json) { + process.stdout.write( + JSON.stringify({ status: 'logged_out', revoked }) + '\n', + ); + } else { + console.log('\n✓ Logged out\n'); + } }); addExamples( diff --git a/src/commands/__tests__/billing.test.ts b/src/commands/__tests__/billing.test.ts index 603dbae..af399b3 100644 --- a/src/commands/__tests__/billing.test.ts +++ b/src/commands/__tests__/billing.test.ts @@ -50,6 +50,18 @@ describe('billingManage — opens the app Billing page (portal retired)', () => expect(vi.mocked(open)).not.toHaveBeenCalled(); }); + + test('When --json, then it emits the billing URL as JSON and opens no browser (AIT-164)', async () => { + vi.mocked(apiClient).mockResolvedValueOnce(workspaces); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await billingManage({ json: true }); + + expect(vi.mocked(open)).not.toHaveBeenCalled(); + const parsed = JSON.parse(logSpy.mock.calls.at(-1)![0] as string); + expect(parsed).toEqual({ billingUrl: 'https://app.test/org/org_abc12345/billing' }); + logSpy.mockRestore(); + }); }); describe('billingUpgrade — active subscription path (portal retired)', () => { diff --git a/src/commands/billing.ts b/src/commands/billing.ts index a9a0835..a0aa586 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -37,10 +37,17 @@ function orgBillingUrl(orgPublicId: string): string { return `${getEffectiveAppUrl()}/org/${orgPublicId}/billing`; } -export async function billingManage(): Promise { +export async function billingManage(opts: { json?: boolean } = {}): Promise { const workspaceId = await getDefaultWorkspaceId(); const url = orgBillingUrl(await resolveOrgPublicId(workspaceId)); + // --json is a machine contract: emit the URL and take NO interactive side + // effect (no browser open, no human text) so agents/CI get clean JSON. + if (opts.json) { + output({ billingUrl: url }, { json: true }); + return; + } + console.log('Opening your Billing page...'); await open(url); } @@ -175,7 +182,8 @@ export function registerBillingCommand(_program: Command): void { .command('manage') .description('Open your Billing page in the app') .action(async () => { - await billingManage(); + const { program: rootProgram } = await import('../index.js'); + await billingManage({ json: !!rootProgram.opts().json }); }); const billingUpgradeCmd = billing