Skip to content
Merged
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
70 changes: 68 additions & 2 deletions src/auth/__tests__/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ afterEach(() => {
vi.restoreAllMocks();
});

async function runLogout(): Promise<void> {
async function runLogout(args: string[] = []): Promise<void> {
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', () => {
Expand All @@ -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();
});
});
33 changes: 31 additions & 2 deletions src/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
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 {
const logout = program
.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(
Expand Down
12 changes: 12 additions & 0 deletions src/commands/__tests__/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
12 changes: 10 additions & 2 deletions src/commands/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,17 @@ function orgBillingUrl(orgPublicId: string): string {
return `${getEffectiveAppUrl()}/org/${orgPublicId}/billing`;
}

export async function billingManage(): Promise<void> {
export async function billingManage(opts: { json?: boolean } = {}): Promise<void> {
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);
}
Expand Down Expand Up @@ -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
Expand Down
Loading