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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,17 @@ Key input field notes:
- **React 18 + Ink 5** for interactive rendering
- **`conf`** for local auth token storage

## Global Flags

| Flag | Effect |
|------|--------|
| `--auth <path>` | Store auth credentials in a specific file instead of the default platform config location. `auth login` writes to this file; all other commands read from it. Parsed from `process.argv` and stripped before incur processes flags. |

## Environment Variables

| Variable | Effect |
|----------|--------|
| `LINK_AUTH_FILE` | Same as `--auth` — override the auth credential file path (flag takes precedence) |
| `LINK_API_BASE_URL` | Override API base URL |
| `LINK_AUTH_BASE_URL` | Override auth base URL |
| `LINK_HTTP_PROXY` | Route all SDK requests through an HTTP proxy (requires `undici` installed) |
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ When you provide `--client-name`, the Link app displays it when you approve the

Set `NO_UPDATE_NOTIFIER=1` to suppress update checks (for example, in CI).

All commands accept `--auth <path>` to store auth credentials in a specific file instead of the default location. `auth login` writes to this file; all other commands read from it. Useful for running multiple sessions with separate identities.

### Spend request lifecycle

A spend request moves through: **create** → **request approval** → **approved** (with credentials).
Expand Down Expand Up @@ -241,6 +243,7 @@ link-cli mpp decode \

| Variable | Effect |
|----------|--------|
| `LINK_AUTH_FILE` | Same as `--auth` — override the auth credential file path (flag takes precedence) |
| `LINK_API_BASE_URL` | Override the API base URL |
| `LINK_AUTH_BASE_URL` | Override the auth base URL |
| `LINK_HTTP_PROXY` | Route all requests through an HTTP proxy (requires `undici`) |
Expand Down
41 changes: 32 additions & 9 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type AuthStorage, Storage, storage } from '@stripe/link-sdk';
import { Cli } from 'incur';
import { createAuthCli } from './commands/auth';
import { createDemoCli } from './commands/demo';
Expand Down Expand Up @@ -27,7 +28,20 @@ const defaultHeaders = {
};

const verbose = process.argv.includes('--verbose');
const factory = new ResourceFactory({ verbose, defaultHeaders });

const authFileIndex = process.argv.indexOf('--auth');
const credentialFilePath =
authFileIndex !== -1
? process.argv[authFileIndex + 1]
: process.env.LINK_AUTH_FILE;
if (authFileIndex !== -1) {
process.argv.splice(authFileIndex, 2);
}
const authStorage: AuthStorage = credentialFilePath
? new Storage({ configPath: credentialFilePath })
: storage;

const factory = new ResourceFactory({ verbose, defaultHeaders, authStorage });
const authRepo = factory.createAuthResource();
const spendRequestRepo = factory.createSpendRequestResource();

Expand All @@ -53,24 +67,33 @@ if (!isAgent && process.stdout.isTTY) {
}
}

cli.command(createAuthCli(authRepo, getUpdateInfo));
cli.command(createSpendRequestCli(spendRequestRepo));
cli.command(createAuthCli(authRepo, getUpdateInfo, authStorage));
cli.command(createSpendRequestCli(spendRequestRepo, authStorage));
cli.command(
createPaymentMethodsCli(() => factory.createPaymentMethodsResource()),
createPaymentMethodsCli(
() => factory.createPaymentMethodsResource(),
authStorage,
),
);
cli.command(
createShippingAddressCli(() => factory.createShippingAddressResource()),
);
cli.command(createUserInfoCli(() => factory.createUserInfoResource()));
cli.command(createMppCli(spendRequestRepo));
cli.command(createMppCli(spendRequestRepo, authStorage));
cli.command(
createDemoCli(authRepo, spendRequestRepo, () =>
factory.createPaymentMethodsResource(),
createDemoCli(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
authStorage,
),
);
cli.command(
createOnboardCli(authRepo, spendRequestRepo, () =>
factory.createPaymentMethodsResource(),
createOnboardCli(
authRepo,
spendRequestRepo,
() => factory.createPaymentMethodsResource(),
authStorage,
),
);

Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/commands/auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { storage } from '@stripe/link-sdk';
import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk';
import { Cli } from 'incur';
import { render } from 'ink';
import React from 'react';
Expand All @@ -12,7 +12,9 @@ import { AuthStatus } from './status';
export function createAuthCli(
authResource: IAuthResource,
getUpdateInfo?: UpdateInfoProvider,
authStorage?: AuthStorage,
) {
const storage = authStorage ?? defaultStorage;
const cli = Cli.create('auth', {
description: 'Authentication commands',
});
Expand All @@ -36,6 +38,7 @@ export function createAuthCli(
<Login
authResource={authResource}
clientName={clientName}
authStorage={storage}
onComplete={() => {}}
/>,
);
Expand Down Expand Up @@ -89,7 +92,11 @@ export function createAuthCli(
if (!c.agent && !c.formatExplicit) {
return new Promise((resolve) => {
const { waitUntilExit } = render(
<Logout authResource={authResource} onComplete={() => {}} />,
<Logout
authResource={authResource}
authStorage={storage}
onComplete={() => {}}
/>,
);
waitUntilExit().then(() => resolve(result));
});
Expand Down Expand Up @@ -122,7 +129,7 @@ export function createAuthCli(
if (!c.agent && !c.formatExplicit) {
return new Promise((resolve) => {
const { waitUntilExit } = render(
<AuthStatus onComplete={() => {}} />,
<AuthStatus authStorage={storage} onComplete={() => {}} />,
);
waitUntilExit().then(() => {
const auth = storage.getAuth();
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/auth/login.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { storage } from '@stripe/link-sdk';
import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import type React from 'react';
Expand All @@ -10,14 +10,17 @@ import { openUrl } from '../../utils/open-url';
interface LoginProps {
authResource: IAuthResource;
clientName?: string;
authStorage?: AuthStorage;
onComplete: () => void;
}

export const Login: React.FC<LoginProps> = ({
authResource,
clientName,
authStorage = defaultStorage,
onComplete,
}) => {
const storage = authStorage;
const [status, setStatus] = useState<
'initiating' | 'waiting' | 'polling' | 'success' | 'error'
>('initiating');
Expand Down Expand Up @@ -82,7 +85,7 @@ export const Login: React.FC<LoginProps> = ({
// Wait 1 second before starting to poll
const timeout = setTimeout(startPolling, 1000);
return () => clearTimeout(timeout);
}, [status, deviceCode, authResource, onComplete]);
}, [status, deviceCode, authResource, onComplete, storage]);

if (status === 'initiating') {
return (
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/auth/logout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { storage } from '@stripe/link-sdk';
import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk';
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState } from 'react';
Expand All @@ -7,10 +7,16 @@ import { DISPLAY_DELAY_MS } from '../../utils/constants';

interface LogoutProps {
authResource: IAuthResource;
authStorage?: AuthStorage;
onComplete: () => void;
}

export const Logout: React.FC<LogoutProps> = ({ authResource, onComplete }) => {
export const Logout: React.FC<LogoutProps> = ({
authResource,
authStorage = defaultStorage,
onComplete,
}) => {
const storage = authStorage;
const [done, setDone] = useState(false);

useEffect(() => {
Expand All @@ -29,7 +35,7 @@ export const Logout: React.FC<LogoutProps> = ({ authResource, onComplete }) => {
setTimeout(onComplete, DISPLAY_DELAY_MS);
};
run();
}, [authResource, onComplete]);
}, [authResource, onComplete, storage]);

if (!done) {
return null;
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/auth/status.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { storage } from '@stripe/link-sdk';
import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk';
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState } from 'react';
import { DISPLAY_DELAY_MS } from '../../utils/constants';

interface AuthStatusProps {
authStorage?: AuthStorage;
onComplete: () => void;
}

export const AuthStatus: React.FC<AuthStatusProps> = ({ onComplete }) => {
export const AuthStatus: React.FC<AuthStatusProps> = ({
authStorage = defaultStorage,
onComplete,
}) => {
const storage = authStorage;
const [checked, setChecked] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [tokenPreview, setTokenPreview] = useState('');
Expand All @@ -26,7 +31,7 @@ export const AuthStatus: React.FC<AuthStatusProps> = ({ onComplete }) => {
setCredentialsPath(credentialsPath);
setChecked(true);
setTimeout(onComplete, DISPLAY_DELAY_MS);
}, [onComplete]);
}, [onComplete, storage]);

if (!checked) {
return null;
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/demo/demo-runner.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type {
AuthStorage,
IPaymentMethodsResource,
ISpendRequestResource,
} from '@stripe/link-sdk';
import { storage } from '@stripe/link-sdk';
import { storage as defaultStorage } from '@stripe/link-sdk';
import { Box, Text, useInput } from 'ink';
import type React from 'react';
import { useCallback, useState } from 'react';
Expand All @@ -28,6 +29,7 @@ interface DemoRunnerProps {
authRepo: IAuthResource;
spendRequestRepo: ISpendRequestResource;
paymentMethodsResource: IPaymentMethodsResource;
authStorage?: AuthStorage;
paymentMethodId?: string;
onlyCard?: boolean;
onlySpt?: boolean;
Expand All @@ -38,11 +40,13 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
authRepo,
spendRequestRepo,
paymentMethodsResource,
authStorage = defaultStorage,
paymentMethodId: preselectedPmId,
onlyCard,
onlySpt,
onComplete,
}) => {
const storage = authStorage;
const preselected = onlyCard ? 'card' : onlySpt ? 'spt' : null;
const [choice, setChoice] = useState<Choice | null>(preselected);
const [menuIndex, setMenuIndex] = useState(0);
Expand Down Expand Up @@ -113,6 +117,7 @@ export const DemoRunner: React.FC<DemoRunnerProps> = ({
<Login
authResource={authRepo}
clientName={O.auth.clientName}
authStorage={storage}
onComplete={() => setPhase(postAuthPhase)}
/>
)}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/demo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AuthStorage,
IPaymentMethodsResource,
ISpendRequestResource,
} from '@stripe/link-sdk';
Expand All @@ -23,6 +24,7 @@ export function createDemoCli(
authRepo: IAuthResource,
spendRequestRepo: ISpendRequestResource,
createPaymentMethodsResource: () => IPaymentMethodsResource,
authStorage?: AuthStorage,
) {
return Cli.create('demo', {
description:
Expand All @@ -45,6 +47,7 @@ export function createDemoCli(
authRepo={authRepo}
spendRequestRepo={spendRequestRepo}
paymentMethodsResource={paymentMethodsResource}
authStorage={authStorage}
onlyCard={c.options.onlyCard}
onlySpt={c.options.onlySpt}
onComplete={() => unmount()}
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/commands/mpp/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ISpendRequestResource } from '@stripe/link-sdk';
import type { AuthStorage, ISpendRequestResource } from '@stripe/link-sdk';
import { Cli, z } from 'incur';
import { render } from 'ink';
import React from 'react';
Expand All @@ -8,7 +8,10 @@ import { DecodeChallengeView } from './decode-view';
import { MppPay, runMppPay } from './pay';
import { decodeOptions, payOptions } from './schema';

export function createMppCli(repository: ISpendRequestResource) {
export function createMppCli(
repository: ISpendRequestResource,
authStorage?: AuthStorage,
) {
const cli = Cli.create('mpp', {
description: 'Machine payment protocol (MPP) commands',
});
Expand All @@ -22,7 +25,7 @@ export function createMppCli(repository: ISpendRequestResource) {
options: payOptions,
alias: { method: 'X', data: 'd', header: 'H' },
outputPolicy: 'agent-only' as const,
middleware: [requireAuth],
middleware: [requireAuth(authStorage)],
async run(c) {
const url = c.args.url;
const opts = c.options;
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands/onboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
AuthStorage,
IPaymentMethodsResource,
ISpendRequestResource,
} from '@stripe/link-sdk';
Expand All @@ -12,6 +13,7 @@ export function createOnboardCli(
authRepo: IAuthResource,
spendRequestRepo: ISpendRequestResource,
createPaymentMethodsResource: () => IPaymentMethodsResource,
authStorage?: AuthStorage,
) {
return Cli.create('onboard', {
description:
Expand All @@ -33,6 +35,7 @@ export function createOnboardCli(
authRepo={authRepo}
spendRequestRepo={spendRequestRepo}
paymentMethodsResource={paymentMethodsResource}
authStorage={authStorage}
onComplete={() => unmount()}
/>,
);
Expand Down
Loading
Loading