Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ scripts/test-commands.sh

# CodeGraph
.codegraph/

# GitNexus (code intelligence index)
.gitnexus/

# Claude/Cursor AI config (not Kiro)
.claude/
CLAUDE.md
AGENTS.md
11 changes: 11 additions & 0 deletions apps/token-cli/src/payment-methods/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export function registerAddCommand(parent: Command, deps: AddDeps): void {
.option(
'--idempotency-key <key>',
'Idempotency key forwarded verbatim as the Idempotency-Key header (manual mode only)',
)
.option(
'--no-poll',
'Dropin mode: mint the session, print it, and exit immediately without polling verification status (for server/SDK-driven flows where the front-end completes the binding)',
);

cmd.action(async () => {
Expand Down Expand Up @@ -309,6 +313,13 @@ async function handleDropinMode(
'Use the Session ID to add the payment method in the browser via the Drop-in SDK',
);

// --no-poll: server/SDK-driven flows finish the binding in the front-end, so
// the CLI mints + prints the session and exits immediately. The session id is
// already on stdout (clean JSON in --format json), so the caller can parse it.
if (opts.poll === false) {
return;
}

// 3) Poll verification/status (same endpoint manual mode uses) until the PM
// reaches a terminal status or we time out at 30 minutes.
const finalPm = await pollVerificationStatus(deps.apiClient, apiKey, pmId, {
Expand Down
62 changes: 61 additions & 1 deletion apps/token-cli/tests/payment-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { registerListCommand } from '../src/payment-methods/list.js';
import { registerGetCommand } from '../src/payment-methods/get.js';
import { registerDisableCommand } from '../src/payment-methods/disable.js';
import { registerAddCommand } from '../src/payment-methods/add.js';
import { buildProgram, captureStdout, captureStderr, mockApiClient } from './helpers.js';
import { buildProgram, captureStdout, captureStderr, mockApiClient, parseJsonOutput } from './helpers.js';

afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -543,4 +543,64 @@ describe('payment-methods add --mode dropin', () => {
expect(out.text()).toContain('pm_dropin');
expect(process.exitCode).toBe(1);
});

it('--no-poll: mints + prints the session and exits immediately without polling', async () => {
const apiClient = dropinClient({ id: 'pm_dropin', status: 'PENDING' });

const program = buildProgram();
const cmd = program.command('payment-methods');
registerAddCommand(cmd, { apiClient } as any);

const out = captureStdout();
const err = captureStderr();

await program.parseAsync([
'node', 'cli', 'payment-methods', 'add',
'--mode', 'dropin',
'--no-poll',
'--api-key', 'sk_key',
'--email', 'user@example.com',
]);

// Session minted and printed
expect(apiClient.post).toHaveBeenCalledWith(
'/payment-methods/dropin/create',
{ type: 'api-key', key: 'sk_key' },
{ email: 'user@example.com' },
);
expect(out.text()).toContain('sess_abc123');
expect(err.text()).toContain('Drop-in session created');

// Crucially: no polling of verification/status
expect(apiClient.get).not.toHaveBeenCalled();

// Clean exit
expect(process.exitCode === 0 || process.exitCode === undefined).toBe(true);
});

it('--no-poll --format json: stdout is clean parseable JSON with session_id', async () => {
const apiClient = dropinClient({ id: 'pm_dropin', status: 'PENDING' });

const program = buildProgram();
const cmd = program.command('payment-methods');
registerAddCommand(cmd, { apiClient } as any);

const out = captureStdout();
captureStderr();

await program.parseAsync([
'node', 'cli', '--format', 'json', 'payment-methods', 'add',
'--mode', 'dropin',
'--no-poll',
'--api-key', 'sk_key',
'--email', 'user@example.com',
]);

// stdout must be parseable JSON carrying session_id (orchestrator parses it)
const parsed = parseJsonOutput(out.text()) as Record<string, unknown>;
expect(parsed.session_id).toBe('sess_abc123');

// no polling happened
expect(apiClient.get).not.toHaveBeenCalled();
});
});