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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Key input field notes:
- `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`.
- `retrieve --interval <seconds>` polls until approved/denied/expired/succeeded/failed. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`.
- `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working)
- `--output-file <path>` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file.

### mpp pay

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ link-cli spend-request retrieve lsrq_001 --format json
```
By default, retrieving a spend request will not include card details. Use the `--include=card` to see unmasked card details.

To avoid leaking card credentials into agent transcripts or logs, use `--output-file` to write the full card to a secure local file while stdout shows only redacted data (brand, last4, expiry):

```bash
link-cli spend-request retrieve lsrq_001 --include card --output-file /tmp/link-card.json --format json
```

The file is created with `0600` permissions. If the file already exists, the command fails unless `--force` is passed. When `--output-file` is set, the JSON output replaces the `card` object with redacted fields and adds a `card_output_file` path.

For agent polling, pass `--interval` and optionally `--max-attempts`:

```bash
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/src/commands/spend-request/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { writeCredentialFile } from '../../utils/credential-output';
import { AppDownloadQrCodes } from './app-download-qr-codes';
import { ApprovalWaitingView } from './approval-waiting-view';
import { useApprovalPolling } from './use-approval-polling';
Expand All @@ -15,20 +16,26 @@ interface CreateSpendRequestProps {
repository: ISpendRequestResource;
params: CreateSpendRequestParams;
requestApproval?: boolean;
outputFile?: string;
force?: boolean;
onComplete: (result: SpendRequest | null) => void;
}

export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
repository,
params,
requestApproval = false,
outputFile,
force,
onComplete,
}) => {
const [status, setStatus] = useState<
'creating' | 'waiting' | 'polling' | 'success' | 'error'
>('creating');
const [request, setRequest] = useState<SpendRequest | null>(null);
const [error, setError] = useState<string>('');
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
const [fileError, setFileError] = useState<string>('');

const approvalUrl = request?.approval_url ?? '';

Expand Down Expand Up @@ -71,6 +78,22 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
create();
}, [repository, params, requestApproval, onComplete]);

useEffect(() => {
if (status !== 'success' || !outputFile || !request?.card) return;

const fileData = {
spend_request_id: request.id,
merchant_name: request.merchant_name,
merchant_url: request.merchant_url,
context: request.context,
created_at: request.created_at,
card: request.card,
};
writeCredentialFile(outputFile, fileData, force ?? false)
.then((path) => setOutputFilePath(path))
.catch((err) => setFileError((err as Error).message));
}, [status, outputFile, force, request]);

if (status === 'creating') {
return (
<Box>
Expand Down Expand Up @@ -125,6 +148,51 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
</Text>
)}
</Box>
{request?.card && !outputFile && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Card Details:</Text>
<Text>
{' '}
Number: <Text bold>{request.card.number}</Text>
</Text>
<Text>
{' '}
Brand: <Text bold>{request.card.brand}</Text>
</Text>
<Text>
{' '}
Expiry:{' '}
<Text bold>
{String(request.card.exp_month).padStart(2, '0')}/
{request.card.exp_year}
</Text>
</Text>
{request.card.cvc && (
<Text>
{' '}
CVC: <Text bold>{request.card.cvc}</Text>
</Text>
)}
{request.card.valid_until && (
<Text>
{' '}
Valid Until: <Text bold>{request.card.valid_until}</Text>
</Text>
)}
</Box>
)}
{request?.card && outputFile && (
<Box flexDirection="column" marginTop={1}>
{outputFilePath && (
<Text color="green">
Card credentials written to <Text bold>{outputFilePath}</Text>
</Text>
)}
{fileError && (
<Text color="red">Failed to write card file: {fileError}</Text>
)}
</Box>
)}
<AppDownloadQrCodes />
</Box>
);
Expand Down
61 changes: 54 additions & 7 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { storage } from '@stripe/link-sdk';
import { Cli, z } from 'incur';
import { render } from 'ink';
import React from 'react';
import { writeCredentialFile } from '../../utils/credential-output';
import {
parseLineItemFlag,
parseTotalFlag,
Expand All @@ -19,6 +20,29 @@ import { RetrieveSpendRequest } from './retrieve';
import { createOptions, retrieveOptions, updateOptions } from './schema';
import { UpdateSpendRequest } from './update';

async function applyOutputFile(
request: SpendRequest,
outputFile: string | undefined,
force: boolean,
): Promise<SpendRequest & { card_output_file?: string }> {
if (!outputFile || !request.card) return request;

const fileData = {
spend_request_id: request.id,
merchant_name: request.merchant_name,
merchant_url: request.merchant_url,
context: request.context,
created_at: request.created_at,
card: request.card,
};
const resolvedPath = await writeCredentialFile(outputFile, fileData, force);
const { card: _, ...withoutCard } = request;
return {
...withoutCard,
card_output_file: resolvedPath,
} as SpendRequest & { card_output_file?: string };
}

export function createSpendRequestCli(repository: ISpendRequestResource) {
const cli = Cli.create('spend-request', {
description: 'Spend request management commands',
Expand Down Expand Up @@ -110,6 +134,9 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
test: opts.test ? true : undefined,
};

const outputFile = opts.outputFile;
const forceOverwrite = opts.force;

if (!c.agent && !c.formatExplicit) {
return new Promise((resolve) => {
let capturedResult: SpendRequest | null = null;
Expand All @@ -118,6 +145,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
repository={repository}
params={createParams}
requestApproval={requestApproval}
outputFile={outputFile}
force={forceOverwrite}
onComplete={(result) => {
capturedResult = result;
}}
Expand All @@ -133,7 +162,15 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
// The agent drives the polling loop via `spend-request retrieve`.
const created = await repository.createSpendRequest(createParams);
if (!requestApproval) {
yield created;
try {
yield await applyOutputFile(created, outputFile, forceOverwrite);
} catch (err) {
const message = (err as Error).message;
if (message.startsWith('OUTPUT_FILE_EXISTS')) {
return c.error({ code: 'OUTPUT_FILE_EXISTS', message });
}
return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message });
}
return;
}
yield {
Expand Down Expand Up @@ -291,6 +328,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
const maxAttempts = opts.maxAttempts;
const includeArr = opts.include;
const include = includeArr?.length ? includeArr : undefined;
const outputFile = opts.outputFile;
const forceOverwrite = opts.force;

if (!c.agent && !c.formatExplicit) {
return new Promise((resolve) => {
Expand All @@ -301,6 +340,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
id={id}
timeout={timeout}
include={include}
outputFile={outputFile}
force={forceOverwrite}
onComplete={(result) => {
capturedResult = result;
}}
Expand Down Expand Up @@ -332,16 +373,22 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
});
}

if (terminalStatuses.has(request.status)) {
yield request;
const shouldEmitFinal =
terminalStatuses.has(request.status) || interval <= 0;
if (shouldEmitFinal) {
try {
yield await applyOutputFile(request, outputFile, forceOverwrite);
} catch (err) {
const message = (err as Error).message;
if (message.startsWith('OUTPUT_FILE_EXISTS')) {
return c.error({ code: 'OUTPUT_FILE_EXISTS', message });
}
return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message });
}
return;
}

attempts++;
if (interval <= 0) {
yield request;
return;
}

const maxAttemptsExhausted = maxAttempts > 0 && attempts >= maxAttempts;
const timeoutReached = Date.now() >= deadline;
Expand Down
37 changes: 36 additions & 1 deletion packages/cli/src/commands/spend-request/retrieve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import { writeCredentialFile } from '../../utils/credential-output';

interface RetrieveSpendRequestProps {
repository: ISpendRequestResource;
id: string;
timeout?: number;
include?: string[];
outputFile?: string;
force?: boolean;
onComplete: (result: SpendRequest | null) => void;
}

Expand All @@ -25,12 +28,16 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
id,
timeout = 300,
include,
outputFile,
force,
onComplete,
}) => {
const [phase, setPhase] = useState<Phase>('fetching');
const [request, setRequest] = useState<SpendRequest | null>(null);
const [error, setError] = useState<string>('');
const [elapsed, setElapsed] = useState<number>(0);
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
const [fileError, setFileError] = useState<string>('');
const startTimeRef = useRef<number>(Date.now());
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
Expand All @@ -43,6 +50,22 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
};
}, []);

useEffect(() => {
if (phase !== 'success' || !outputFile || !request?.card) return;

const fileData = {
spend_request_id: request.id,
merchant_name: request.merchant_name,
merchant_url: request.merchant_url,
context: request.context,
created_at: request.created_at,
card: request.card,
};
writeCredentialFile(outputFile, fileData, force ?? false)
.then((path) => setOutputFilePath(path))
.catch((err) => setFileError((err as Error).message));
}, [phase, outputFile, force, request]);

useEffect(() => {
const fetch = async () => {
try {
Expand Down Expand Up @@ -251,7 +274,7 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
</Text>
</Box>
)}
{request?.card && (
{request?.card && !outputFile && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Card Details:</Text>
<Text>
Expand Down Expand Up @@ -317,6 +340,18 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
)}
</Box>
)}
{request?.card && outputFile && (
<Box flexDirection="column" marginTop={1}>
{outputFilePath && (
<Text color="green">
Card credentials written to <Text bold>{outputFilePath}</Text>
</Text>
)}
{fileError && (
<Text color="red">Failed to write card file: {fileError}</Text>
)}
</Box>
)}
</Box>
</Box>
);
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/commands/spend-request/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export const createOptions = z.object({
.describe(
'Use test mode (creates testmode credentials from test card data)',
),
outputFile: z
.string()
.optional()
.describe(
'Write full card credentials to this file path; stdout shows redacted card data only',
),
force: z
.boolean()
.default(false)
.describe('Overwrite output file if it already exists'),
});

export const retrieveOptions = z.object({
Expand All @@ -82,6 +92,16 @@ export const retrieveOptions = z.object({
.array(z.string())
.default([])
.describe('Include extra data (repeatable, e.g. --include card)'),
outputFile: z
.string()
.optional()
.describe(
'Write full card credentials to this file path; stdout shows redacted card data only',
),
force: z
.boolean()
.default(false)
.describe('Overwrite output file if it already exists'),
});

export const updateOptions = z.object({
Expand Down
Loading
Loading