From 8b71215e40b6fd7e23f4ca53a9beceafa418a21a Mon Sep 17 00:00:00 2001 From: System Administrator Date: Fri, 8 May 2026 13:30:00 -0400 Subject: [PATCH] fix: apply improvements to new code areas --- packages/cli/src/cli.tsx | 9 +++- packages/cli/src/commands/auth/logout.tsx | 45 ++++++++++--------- .../cli/src/commands/demo/demo-runner.tsx | 17 ++++--- packages/cli/src/commands/demo/index.tsx | 28 ++++++------ packages/cli/src/commands/onboard/index.tsx | 24 +++++----- .../src/commands/onboard/onboard-runner.tsx | 4 +- .../cli/src/commands/payment-methods/list.tsx | 32 +++---------- .../src/commands/shipping-address/index.tsx | 32 ++++--------- .../src/commands/shipping-address/list.tsx | 38 +++++----------- packages/cli/src/commands/user-info/index.tsx | 36 +++++---------- .../cli/src/commands/user-info/retrieve.tsx | 29 +++--------- 11 files changed, 114 insertions(+), 180 deletions(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 2351221..1f39328 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -76,9 +76,14 @@ cli.command( ), ); cli.command( - createShippingAddressCli(() => factory.createShippingAddressResource()), + createShippingAddressCli( + () => factory.createShippingAddressResource(), + authStorage, + ), +); +cli.command( + createUserInfoCli(() => factory.createUserInfoResource(), authStorage), ); -cli.command(createUserInfoCli(() => factory.createUserInfoResource())); cli.command(createMppCli(spendRequestRepo, authStorage)); cli.command( createDemoCli( diff --git a/packages/cli/src/commands/auth/logout.tsx b/packages/cli/src/commands/auth/logout.tsx index e08f58b..3597723 100644 --- a/packages/cli/src/commands/auth/logout.tsx +++ b/packages/cli/src/commands/auth/logout.tsx @@ -1,9 +1,10 @@ import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; import type { IAuthResource } from '../../auth/types'; -import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { useAsyncAction } from '../../hooks/use-async-action'; interface LogoutProps { authResource: IAuthResource; @@ -17,28 +18,30 @@ export const Logout: React.FC = ({ onComplete, }) => { const storage = authStorage; - const [done, setDone] = useState(false); - useEffect(() => { - const run = async () => { - const auth = storage.getAuth(); - if (auth?.refresh_token) { - try { - await authResource.revokeToken(auth.refresh_token); - } catch { - // best-effort: clear local storage regardless - } + const action = useCallback(async () => { + const auth = storage.getAuth(); + if (auth?.refresh_token) { + try { + await authResource.revokeToken(auth.refresh_token); + } catch { + // best-effort: clear local storage regardless } - storage.clearAuth(); - storage.deleteConfig(); - setDone(true); - setTimeout(onComplete, DISPLAY_DELAY_MS); - }; - run(); - }, [authResource, onComplete, storage]); + } + storage.clearAuth(); + storage.deleteConfig(); + }, [authResource, storage]); - if (!done) { - return null; + const { status } = useAsyncAction(action, onComplete); + + if (status === 'loading') { + return ( + + + Logging out... + + + ); } return ( diff --git a/packages/cli/src/commands/demo/demo-runner.tsx b/packages/cli/src/commands/demo/demo-runner.tsx index 925672c..f9c2ed5 100644 --- a/packages/cli/src/commands/demo/demo-runner.tsx +++ b/packages/cli/src/commands/demo/demo-runner.tsx @@ -4,7 +4,7 @@ import type { ISpendRequestResource, } from '@stripe/link-sdk'; import { storage as defaultStorage } from '@stripe/link-sdk'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; import { useCallback, useState } from 'react'; import type { IAuthResource } from '../../auth/types'; @@ -46,6 +46,7 @@ export const DemoRunner: React.FC = ({ onlySpt, onComplete, }) => { + const { exit } = useApp(); const storage = authStorage; const preselected = onlyCard ? 'card' : onlySpt ? 'spt' : null; const [choice, setChoice] = useState(preselected); @@ -88,21 +89,27 @@ export const DemoRunner: React.FC = ({ if (!runSpt) { setPhase('summary'); - setTimeout(onComplete, DISPLAY_DELAY_MS); + setTimeout(() => { + onComplete(); + exit(); + }, DISPLAY_DELAY_MS); } else { setPhase('card-done'); } }, - [runSpt, onComplete], + [runSpt, onComplete, exit], ); const onSptComplete = useCallback( (success: boolean) => { setSptSuccess(success); setPhase('summary'); - setTimeout(onComplete, DISPLAY_DELAY_MS); + setTimeout(() => { + onComplete(); + exit(); + }, DISPLAY_DELAY_MS); }, - [onComplete], + [onComplete, exit], ); return ( diff --git a/packages/cli/src/commands/demo/index.tsx b/packages/cli/src/commands/demo/index.tsx index 08200a1..4900f4c 100644 --- a/packages/cli/src/commands/demo/index.tsx +++ b/packages/cli/src/commands/demo/index.tsx @@ -4,9 +4,9 @@ import type { ISpendRequestResource, } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; -import { render } from 'ink'; import React from 'react'; import type { IAuthResource } from '../../auth/types'; +import { renderInteractive } from '../../utils/render-interactive'; import { DemoRunner } from './demo-runner'; const demoOptions = z.object({ @@ -41,20 +41,18 @@ export function createDemoCli( const paymentMethodsResource = createPaymentMethodsResource(); - return new Promise((resolve) => { - const { waitUntilExit, unmount } = render( - unmount()} - />, - ); - waitUntilExit().then(() => resolve({})); - }); + return renderInteractive( + {}} + />, + () => ({}), + ); }, }); } diff --git a/packages/cli/src/commands/onboard/index.tsx b/packages/cli/src/commands/onboard/index.tsx index a39d831..bebb87f 100644 --- a/packages/cli/src/commands/onboard/index.tsx +++ b/packages/cli/src/commands/onboard/index.tsx @@ -4,9 +4,9 @@ import type { ISpendRequestResource, } from '@stripe/link-sdk'; import { Cli } from 'incur'; -import { render } from 'ink'; import React from 'react'; import type { IAuthResource } from '../../auth/types'; +import { renderInteractive } from '../../utils/render-interactive'; import { OnboardRunner } from './onboard-runner'; export function createOnboardCli( @@ -29,18 +29,16 @@ export function createOnboardCli( const paymentMethodsResource = createPaymentMethodsResource(); - return new Promise((resolve) => { - const { waitUntilExit, unmount } = render( - unmount()} - />, - ); - waitUntilExit().then(() => resolve({})); - }); + return renderInteractive( + {}} + />, + () => ({}), + ); }, }); } diff --git a/packages/cli/src/commands/onboard/onboard-runner.tsx b/packages/cli/src/commands/onboard/onboard-runner.tsx index b5a40d0..e4c92c8 100644 --- a/packages/cli/src/commands/onboard/onboard-runner.tsx +++ b/packages/cli/src/commands/onboard/onboard-runner.tsx @@ -4,7 +4,7 @@ import type { ISpendRequestResource, } from '@stripe/link-sdk'; import { storage as defaultStorage } from '@stripe/link-sdk'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import type { IAuthResource } from '../../auth/types'; @@ -29,6 +29,7 @@ export const OnboardRunner: React.FC = ({ authStorage = defaultStorage, onComplete, }) => { + const { exit } = useApp(); const storage = authStorage; const [phase, setPhase] = useState('welcome'); const [authSkipped, setAuthSkipped] = useState(false); @@ -90,6 +91,7 @@ export const OnboardRunner: React.FC = ({ setPhase('demo'); } catch (err) { setError((err as Error).message); + setTimeout(exit, 2000); } }; run(); diff --git a/packages/cli/src/commands/payment-methods/list.tsx b/packages/cli/src/commands/payment-methods/list.tsx index 4fd024e..4994d76 100644 --- a/packages/cli/src/commands/payment-methods/list.tsx +++ b/packages/cli/src/commands/payment-methods/list.tsx @@ -2,40 +2,20 @@ import type { IPaymentMethodsResource, PaymentMethod } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useEffect, useState } from 'react'; -import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; interface PaymentMethodsListProps { resource: IPaymentMethodsResource; - onComplete: () => void; + onComplete: (result: PaymentMethod[] | null) => void; } export const PaymentMethodsList: React.FC = ({ resource, onComplete, }) => { - const [status, setStatus] = useState<'loading' | 'success' | 'error'>( - 'loading', - ); - const [methods, setMethods] = useState([]); - const [error, setError] = useState(''); - - useEffect(() => { - const fetch = async () => { - try { - const result = await resource.listPaymentMethods(); - setMethods(result); - setStatus('success'); - setTimeout(onComplete, DISPLAY_DELAY_MS); - } catch (err) { - setError((err as Error).message); - setStatus('error'); - setTimeout(onComplete, DISPLAY_DELAY_MS); - } - }; - - fetch(); - }, [resource, onComplete]); + const action = useCallback(() => resource.listPaymentMethods(), [resource]); + const { status, data: methods, error } = useAsyncAction(action, onComplete); if (status === 'loading') { return ( @@ -56,7 +36,7 @@ export const PaymentMethodsList: React.FC = ({ ); } - if (methods.length === 0) { + if (!methods || methods.length === 0) { return ( No payment methods found diff --git a/packages/cli/src/commands/shipping-address/index.tsx b/packages/cli/src/commands/shipping-address/index.tsx index 21b7352..68fdc79 100644 --- a/packages/cli/src/commands/shipping-address/index.tsx +++ b/packages/cli/src/commands/shipping-address/index.tsx @@ -1,12 +1,13 @@ -import type { IShippingAddressResource } from '@stripe/link-sdk'; -import { storage } from '@stripe/link-sdk'; +import type { AuthStorage, IShippingAddressResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; -import { render } from 'ink'; import React from 'react'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; import { ShippingAddressList } from './list'; export function createShippingAddressCli( createResource: () => IShippingAddressResource, + authStorage?: AuthStorage, ) { const cli = Cli.create('shipping-address', { description: 'Shipping address management commands', @@ -15,30 +16,15 @@ export function createShippingAddressCli( cli.command('list', { description: 'List all shipping addresses on your account', outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage)], async run(c) { - if (!storage.isAuthenticated()) { - return c.error({ - code: 'NOT_AUTHENTICATED', - message: 'Not authenticated. Run "link-cli auth login" first.', - cta: { - commands: [ - { command: 'auth login', description: 'Log in to Link' }, - ], - }, - }); - } - const resource = createResource(); if (!c.agent && !c.formatExplicit) { - return new Promise((resolve) => { - const { waitUntilExit } = render( - {}} />, - ); - waitUntilExit().then(async () => { - resolve(await resource.listShippingAddresses()); - }); - }); + return renderInteractive( + {}} />, + () => resource.listShippingAddresses(), + ); } return resource.listShippingAddresses(); diff --git a/packages/cli/src/commands/shipping-address/list.tsx b/packages/cli/src/commands/shipping-address/list.tsx index 2bcd50b..b0676e4 100644 --- a/packages/cli/src/commands/shipping-address/list.tsx +++ b/packages/cli/src/commands/shipping-address/list.tsx @@ -6,11 +6,12 @@ import type { import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; interface ShippingAddressListProps { resource: IShippingAddressResource; - onComplete: () => void; + onComplete: (result: ShippingAddressRecord[] | null) => void; } function formatStreetLine(address: ShippingAddress): string | null { @@ -57,30 +58,15 @@ export const ShippingAddressList: React.FC = ({ resource, onComplete, }) => { - const [status, setStatus] = useState<'loading' | 'success' | 'error'>( - 'loading', + const action = useCallback( + () => resource.listShippingAddresses(), + [resource], ); - const [shippingAddresses, setShippingAddresses] = useState< - ShippingAddressRecord[] - >([]); - const [error, setError] = useState(''); - - useEffect(() => { - const fetch = async () => { - try { - const result = await resource.listShippingAddresses(); - setShippingAddresses(result); - setStatus('success'); - setTimeout(onComplete, 1500); - } catch (err) { - setError((err as Error).message); - setStatus('error'); - setTimeout(onComplete, 1500); - } - }; - - fetch(); - }, [resource, onComplete]); + const { + status, + data: shippingAddresses, + error, + } = useAsyncAction(action, onComplete); if (status === 'loading') { return ( @@ -101,7 +87,7 @@ export const ShippingAddressList: React.FC = ({ ); } - if (shippingAddresses.length === 0) { + if (!shippingAddresses || shippingAddresses.length === 0) { return ( No shipping addresses found diff --git a/packages/cli/src/commands/user-info/index.tsx b/packages/cli/src/commands/user-info/index.tsx index 6caff04..b76d681 100644 --- a/packages/cli/src/commands/user-info/index.tsx +++ b/packages/cli/src/commands/user-info/index.tsx @@ -1,11 +1,14 @@ -import type { IUserInfoResource } from '@stripe/link-sdk'; -import { storage } from '@stripe/link-sdk'; +import type { AuthStorage, IUserInfoResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; -import { render } from 'ink'; import React from 'react'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; import { UserInfoRetrieve } from './retrieve'; -export function createUserInfoCli(createResource: () => IUserInfoResource) { +export function createUserInfoCli( + createResource: () => IUserInfoResource, + authStorage?: AuthStorage, +) { const cli = Cli.create('user-info', { description: 'User information commands', }); @@ -13,30 +16,15 @@ export function createUserInfoCli(createResource: () => IUserInfoResource) { cli.command('retrieve', { description: 'Retrieve user info (email, name, phone)', outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage)], async run(c) { - if (!storage.isAuthenticated()) { - return c.error({ - code: 'NOT_AUTHENTICATED', - message: 'Not authenticated. Run "link-cli auth login" first.', - cta: { - commands: [ - { command: 'auth login', description: 'Log in to Link' }, - ], - }, - }); - } - const resource = createResource(); if (!c.agent && !c.formatExplicit) { - return new Promise((resolve) => { - const { waitUntilExit } = render( - {}} />, - ); - waitUntilExit().then(async () => { - resolve(await resource.retrieve()); - }); - }); + return renderInteractive( + {}} />, + () => resource.retrieve(), + ); } return resource.retrieve(); diff --git a/packages/cli/src/commands/user-info/retrieve.tsx b/packages/cli/src/commands/user-info/retrieve.tsx index 28442cf..0122bfc 100644 --- a/packages/cli/src/commands/user-info/retrieve.tsx +++ b/packages/cli/src/commands/user-info/retrieve.tsx @@ -2,39 +2,20 @@ import type { IUserInfoResource, UserInfo } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; interface UserInfoRetrieveProps { resource: IUserInfoResource; - onComplete: () => void; + onComplete: (result: UserInfo | null) => void; } export const UserInfoRetrieve: React.FC = ({ resource, onComplete, }) => { - const [status, setStatus] = useState<'loading' | 'success' | 'error'>( - 'loading', - ); - const [userInfo, setUserInfo] = useState(null); - const [error, setError] = useState(''); - - useEffect(() => { - const fetch = async () => { - try { - const result = await resource.retrieve(); - setUserInfo(result); - setStatus('success'); - setTimeout(onComplete, 1500); - } catch (err) { - setError((err as Error).message); - setStatus('error'); - setTimeout(onComplete, 1500); - } - }; - - fetch(); - }, [resource, onComplete]); + const action = useCallback(() => resource.retrieve(), [resource]); + const { status, data: userInfo, error } = useAsyncAction(action, onComplete); if (status === 'loading') { return (