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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ hookmyapp channels connect whatsapp # or name the type directly
hookmyapp channels connect instagram
```

## Authentication

`hookmyapp login` signs you in through your browser and is the default for
interactive use.

**Browser-free sign-in** (for headless environments and AI agents) uses an
emailed one-time code instead of a browser:

```bash
# Interactive terminal: prompts you for the 6-digit code from your email
hookmyapp login --email you@example.com

# Non-interactive / agent: two steps, because the code arrives out of band
hookmyapp login --email you@example.com --json
# -> { "registrationId": "...", "expiresAt": "..." }
hookmyapp login --email you@example.com --registration-id <id> --otp 123456 --json
```

This stores an organization-scoped credential (`ac_…`). Pass `--scope <name>`
(repeatable) to request a narrower set than the default full access.

Manage those credentials:

```bash
hookmyapp credentials list
hookmyapp credentials revoke ac_ab12cd34 -y
```

## Listening for webhooks on localhost

Two flavors — pick based on which channel you have.
Expand Down
68 changes: 68 additions & 0 deletions src/api/__tests__/agent-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { expect, test, beforeEach, afterEach, vi } from 'vitest';

const SAVED = process.env.HOOKMYAPP_API_URL;
beforeEach(() => {
process.env.HOOKMYAPP_API_URL = 'https://test.example.com';
});
afterEach(() => {
if (SAVED) process.env.HOOKMYAPP_API_URL = SAVED;
else delete process.env.HOOKMYAPP_API_URL;
vi.unstubAllGlobals();
});

function okJson(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}

test('fetchSupportedScopes reads scopes_supported from the well-known', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ scopes_supported: ['workspace.read', 'message.send'] })));
const { fetchSupportedScopes } = await import('../agent-auth.js');
expect(await fetchSupportedScopes()).toEqual(['workspace.read', 'message.send']);
});

test('initiateClaim POSTs email + scopes and returns registrationId + expiresAt', async () => {
const fetchMock = vi.fn().mockResolvedValue(
okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: '2026-07-01T00:10:00.000Z', message: 'sent' }, 202),
);
vi.stubGlobal('fetch', fetchMock);
const { initiateClaim } = await import('../agent-auth.js');
const out = await initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://test.example.com/agent/auth/claim');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.com', scopes: ['workspace.read'] });
expect(out.registrationId).toBe('11111111-1111-1111-1111-111111111111');
});

test('completeClaim POSTs registrationId + otp and returns the ac_ credential', async () => {
const fetchMock = vi.fn().mockResolvedValue(
okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub1' }),
);
vi.stubGlobal('fetch', fetchMock);
const { completeClaim } = await import('../agent-auth.js');
const out = await completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '123456' });
expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete');
expect(out.accessToken).toBe('ac_live_x');
expect(out.credentialPublicId).toBe('ac_pub1');
});

test('completeClaim maps a 429 to a typed rate-limit error (exitCode 6)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ code: 'RATE_LIMITED', message: 'slow down' }, 429)));
const { completeClaim } = await import('../agent-auth.js');
await expect(completeClaim({ registrationId: '11111111-1111-1111-1111-111111111111', otp: '000000' })).rejects.toMatchObject({ exitCode: 6 });
});

test('a request timeout maps to a NetworkError (exitCode 5)', async () => {
const timeout = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout));
const { initiateClaim } = await import('../agent-auth.js');
await expect(initiateClaim({ email: 'a@b.com', scopes: ['workspace.read'] })).rejects.toMatchObject({ exitCode: 5 });
});

test('the auth fetch is bounded by an abort signal', async () => {
const fetchMock = vi.fn().mockResolvedValue(okJson({ scopes_supported: [] }));
vi.stubGlobal('fetch', fetchMock);
const { fetchSupportedScopes } = await import('../agent-auth.js');
await fetchSupportedScopes();
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
30 changes: 30 additions & 0 deletions src/api/__tests__/agent-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

let DIR: string;
const SAVED = process.env.HOOKMYAPP_CONFIG_DIR;
beforeEach(() => {
DIR = mkdtempSync(join(tmpdir(), 'hma-agent-refresh-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
mkdirSync(DIR, { recursive: true });
});
afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED) process.env.HOOKMYAPP_CONFIG_DIR = SAVED;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
vi.unstubAllGlobals();
});

test('forceTokenRefresh is a no-op for an agent credential (no WorkOS call)', async () => {
writeFileSync(
join(DIR, 'credentials.json'),
JSON.stringify({ accessToken: 'ac_live_x', refreshToken: '', expiresAt: 0, kind: 'agent', credentialPublicId: 'ac_pub1', scopes: [] }),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const { forceTokenRefresh } = await import('../client.js');
await expect(forceTokenRefresh('org_123')).resolves.toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});
65 changes: 65 additions & 0 deletions src/api/agent-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getEffectiveApiUrl } from '../config/env-profiles.js';
import { NetworkError } from '../output/error.js';
import { mapApiError, isNetworkFailure } from './client.js';

// Re-declared wire DTOs (the backend is never imported). Keep field names in
// lockstep with the auth.md endpoints; integration drift is caught by tests.
export interface ClaimInitiated {
registrationId: string; // UUID
expiresAt: string; // ISO timestamp
}

export interface AgentCredentialResponse {
accessToken: string; // "ac_…" Bearer credential
tokenType: string; // "Bearer"
scopes: string[];
credentialPublicId: string;
expiresAt?: string;
orgId?: string;
}

// Auth requests must not hang an agent or CI job forever; bound every call.
const AUTH_FETCH_TIMEOUT_MS = 30_000;

async function timedFetch(url: string, init: RequestInit): Promise<Response> {
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(AUTH_FETCH_TIMEOUT_MS) });
} catch (err) {
// AbortSignal.timeout aborts with a TimeoutError; treat it, and any
// transport failure, as a NetworkError so the CLI exits cleanly (exit 5).
if (isNetworkFailure(err) || (err instanceof Error && err.name === 'TimeoutError')) {
throw new NetworkError();
}
throw err;
}
}

async function postJson(path: string, body: unknown): Promise<unknown> {
const res = await timedFetch(`${getEffectiveApiUrl()}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw await mapApiError(res);
return res.json();
}

/** Full scope vocabulary advertised by the backend (drift-free default). */
export async function fetchSupportedScopes(): Promise<string[]> {
const res = await timedFetch(
`${getEffectiveApiUrl()}/.well-known/oauth-protected-resource`,
{ method: 'GET' },
);
if (!res.ok) throw await mapApiError(res);
const body = (await res.json()) as { scopes_supported?: string[] };
return Array.isArray(body.scopes_supported) ? body.scopes_supported : [];
}

export async function initiateClaim(input: { email: string; scopes: string[] }): Promise<ClaimInitiated> {
const data = (await postJson('/agent/auth/claim', input)) as ClaimInitiated;
return { registrationId: data.registrationId, expiresAt: data.expiresAt };
}

export async function completeClaim(input: { registrationId: string; otp: string }): Promise<AgentCredentialResponse> {
return (await postJson('/agent/auth/claim/complete', input)) as AgentCredentialResponse;
}
6 changes: 6 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readCredentials, saveCredentials } from '../auth/store.js';
import { isAgentCredential } from '../storage/secrets.js';
import {
AuthError,
ApiError,
Expand Down Expand Up @@ -77,6 +78,11 @@ export async function forceTokenRefresh(organizationId?: string): Promise<void>
if (!creds) {
throw new AuthError('Not logged in. Run: hookmyapp login');
}
// Agent (auth.md) credentials are org-scoped Bearer tokens with no refresh
// token; there is nothing to refresh and no org re-scope to perform.
if (isAgentCredential(creds)) {
return;
}
try {
const refreshed = await refreshToken(creds.refreshToken, organizationId);
await saveCredentials(refreshed);
Expand Down
126 changes: 126 additions & 0 deletions src/auth/__tests__/agentmd-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, test, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

let DIR: string;
const SAVED_DIR = process.env.HOOKMYAPP_CONFIG_DIR;
const SAVED_API_URL = process.env.HOOKMYAPP_API_URL;
function okJson(b: unknown, s = 200): Response {
return new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
}
function readCreds() {
return JSON.parse(readFileSync(join(DIR, 'credentials.json'), 'utf-8'));
}

const inputMock = vi.fn();
vi.mock('@inquirer/prompts', () => ({
input: (...a: unknown[]) => inputMock(...a),
select: vi.fn(),
confirm: vi.fn(),
}));
Comment on lines +16 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- file outline ---\n'
ast-grep outline src/auth/__tests__/agentmd-login.test.ts --view expanded || true

printf '\n--- relevant lines ---\n'
cat -n src/auth/__tests__/agentmd-login.test.ts | sed -n '1,140p'

Repository: hookmyapp/cli

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- file outline ---'
ast-grep outline src/auth/__tests__/agentmd-login.test.ts --view expanded || true

echo
echo '--- relevant lines ---'
cat -n src/auth/__tests__/agentmd-login.test.ts | sed -n '1,140p'

Repository: hookmyapp/cli

Length of output: 5370


🌐 Web query:

Vitest vi.mock hoisted const variable capture vi.hoisted factory reference ReferenceError docs

💡 Result:

The ReferenceError you are encountering occurs because vi.mock is hoisted to the top of the file and executed before any imports [1][2][3]. Consequently, it cannot access variables, functions, or imports defined in the scope of your test file [1][4][5]. To resolve this, use the vi.hoisted method [1][6]. It is designed to execute code before imports and vi.mock calls, and it allows you to return values that can then be referenced inside your vi.mock factory [1][7][6]. Standard usage pattern: 1. Define your mocks using vi.hoisted, which returns an object containing your mock references [1][7]. 2. Reference these returned variables within your vi.mock factory [1][8]. Example implementation: import { vi, expect } from 'vitest' import { originalMethod } from './path/to/module.js' // Define hoisted variables to be used in mocks const { mockedMethod } = vi.hoisted( => { return { mockedMethod: vi.fn } }) // vi.mock can now access the variables defined in vi.hoisted vi.mock('./path/to/module.js', => { return { originalMethod: mockedMethod } }) // Use the mock in your test mockedMethod.mockReturnValue(100) expect(originalMethod).toBe(100) Important considerations: - Imports are not available inside vi.hoisted: Because it runs before imports, trying to access an imported variable inside the vi.hoisted callback will result in a ReferenceError [1][9][7]. - Avoid importing within vi.hoisted: While you can use dynamic imports inside vi.hoisted if absolutely necessary, it is discouraged because imports are already hoisted, and side effects should ideally be managed within the modules themselves [1][7]. - Alternative for non-hoisted scenarios: If you do not require hoisting, you can use vi.doMock instead, which is not hoisted to the top of the file, though it requires that you understand module evaluation order and typically requires manual importing after mocking [10][5].

Citations:


Use vi.hoisted for the prompt mock

vi.mock('@inquirer/prompts', ...) is hoisted, so inputMock can be unavailable when the factory runs. Move the mock into vi.hoisted and reference that hoisted object inside the factory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/auth/__tests__/agentmd-login.test.ts` around lines 15 - 20, The prompt
mock in agentmd-login.test.ts is using a top-level inputMock with vi.mock, but
the mock factory is hoisted and may run before that variable exists. Move the
prompt mock setup into vi.hoisted and have the `@inquirer/prompts` factory
reference the hoisted object instead, using the existing input/select/confirm
mock symbols so the test mock is always initialized safely.


beforeEach(() => {
DIR = mkdtempSync(join(tmpdir(), 'hma-agentlogin-'));
process.env.HOOKMYAPP_CONFIG_DIR = DIR;
process.env.HOOKMYAPP_API_URL = 'https://test.example.com';
inputMock.mockReset();
});
afterEach(() => {
rmSync(DIR, { recursive: true, force: true });
if (SAVED_DIR) process.env.HOOKMYAPP_CONFIG_DIR = SAVED_DIR;
else delete process.env.HOOKMYAPP_CONFIG_DIR;
if (SAVED_API_URL) process.env.HOOKMYAPP_API_URL = SAVED_API_URL;
else delete process.env.HOOKMYAPP_API_URL;
vi.unstubAllGlobals();
});

test('interactive: claims with full scopes, prompts OTP, completes, saves ac_ credential', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read', 'message.send'] })) // discovery
.mockResolvedValueOnce(okJson({ registrationId: '11111111-1111-1111-1111-111111111111', expiresAt: 'x', message: 'sent' }, 202)) // claim
.mockResolvedValueOnce(okJson({ accessToken: 'ac_live_x', tokenType: 'Bearer', scopes: ['workspace.read', 'message.send'], credentialPublicId: 'ac_pub1' })); // complete
vi.stubGlobal('fetch', fetchMock);
inputMock.mockResolvedValue('123456');
const origIsTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const mod = await import('../login.js');

try {
await mod.runAgentClaimLogin({ email: 'a@b.com' });

expect(JSON.parse(fetchMock.mock.calls[1][1].body).scopes).toEqual(['workspace.read', 'message.send']);
const creds = readCreds();
expect(creds.accessToken).toBe('ac_live_x');
expect(creds.kind).toBe('agent');
expect(creds.credentialPublicId).toBe('ac_pub1');
} finally {
logSpy.mockRestore();
if (origIsTty) Object.defineProperty(process.stdin, 'isTTY', origIsTty);
else delete (process.stdin as { isTTY?: boolean }).isTTY;
}
});

test('json step 2 without --otp is a ValidationError and never calls the network', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const mod = await import('../login.js');
await expect(
mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', json: true }),
).rejects.toMatchObject({ exitCode: 2 });
expect(fetchMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalled();
});

test('json split step 1: no --otp prints registrationId + expiresAt and does NOT prompt or save', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(okJson({ scopes_supported: ['workspace.read'] }))
.mockResolvedValueOnce(okJson({ registrationId: '2222', expiresAt: 'later', message: 'sent' }, 202));
vi.stubGlobal('fetch', fetchMock);
const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const mod = await import('../login.js');

await mod.runAgentClaimLogin({ email: 'a@b.com', json: true });

expect(inputMock).not.toHaveBeenCalled();
expect(existsSync(join(DIR, 'credentials.json'))).toBe(false);
const printed = outSpy.mock.calls.flat().join('');
expect(JSON.parse(printed)).toMatchObject({ registrationId: '2222', expiresAt: 'later' });
outSpy.mockRestore();
});

test('json split step 2: --registration-id + --otp completes without a new claim call', async () => {
const fetchMock = vi.fn().mockResolvedValue(
okJson({ accessToken: 'ac_live_y', tokenType: 'Bearer', scopes: ['workspace.read'], credentialPublicId: 'ac_pub2' }),
);
vi.stubGlobal('fetch', fetchMock);
const mod = await import('../login.js');

await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '2222', otp: '654321', json: true });

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toBe('https://test.example.com/agent/auth/claim/complete');
expect(readCreds().accessToken).toBe('ac_live_y');
});

test('--otp without --registration-id is a ValidationError (exit 2)', async () => {
const mod = await import('../login.js');
await expect(mod.runAgentClaimLogin({ email: 'a@b.com', otp: '123456' })).rejects.toMatchObject({ exitCode: 2 });
});

test('agent flags without --email are rejected before any browser flow', async () => {
const { Command } = await import('commander');
const { loginCommand } = await import('../login.js');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const program = new Command();
program.exitOverride();
program.option('--json');
program.option('--human');
loginCommand(program);
await expect(
program.parseAsync(['node', 'hookmyapp', 'login', '--registration-id', 'r1', '--otp', '123456', '--json']),
).rejects.toMatchObject({ exitCode: 2 });
expect(fetchMock).not.toHaveBeenCalled();
});
Loading
Loading