diff --git a/CLAUDE.md b/CLAUDE.md index 7ad7c7f..eb6d40c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` | 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) | diff --git a/README.md b/README.md index cb1f01d..e12f41e 100644 --- a/README.md +++ b/README.md @@ -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 ` 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). @@ -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`) | diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 5a7ccf5..2351221 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -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'; @@ -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(); @@ -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, ), ); diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 1e694ee..865990c 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -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'; @@ -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', }); @@ -36,6 +38,7 @@ export function createAuthCli( {}} />, ); @@ -89,7 +92,11 @@ export function createAuthCli( if (!c.agent && !c.formatExplicit) { return new Promise((resolve) => { const { waitUntilExit } = render( - {}} />, + {}} + />, ); waitUntilExit().then(() => resolve(result)); }); @@ -122,7 +129,7 @@ export function createAuthCli( if (!c.agent && !c.formatExplicit) { return new Promise((resolve) => { const { waitUntilExit } = render( - {}} />, + {}} />, ); waitUntilExit().then(() => { const auth = storage.getAuth(); diff --git a/packages/cli/src/commands/auth/login.tsx b/packages/cli/src/commands/auth/login.tsx index 7c78cc4..807af6f 100644 --- a/packages/cli/src/commands/auth/login.tsx +++ b/packages/cli/src/commands/auth/login.tsx @@ -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'; @@ -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 = ({ authResource, clientName, + authStorage = defaultStorage, onComplete, }) => { + const storage = authStorage; const [status, setStatus] = useState< 'initiating' | 'waiting' | 'polling' | 'success' | 'error' >('initiating'); @@ -82,7 +85,7 @@ export const Login: React.FC = ({ // 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 ( diff --git a/packages/cli/src/commands/auth/logout.tsx b/packages/cli/src/commands/auth/logout.tsx index c3bdde0..e08f58b 100644 --- a/packages/cli/src/commands/auth/logout.tsx +++ b/packages/cli/src/commands/auth/logout.tsx @@ -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'; @@ -7,10 +7,16 @@ import { DISPLAY_DELAY_MS } from '../../utils/constants'; interface LogoutProps { authResource: IAuthResource; + authStorage?: AuthStorage; onComplete: () => void; } -export const Logout: React.FC = ({ authResource, onComplete }) => { +export const Logout: React.FC = ({ + authResource, + authStorage = defaultStorage, + onComplete, +}) => { + const storage = authStorage; const [done, setDone] = useState(false); useEffect(() => { @@ -29,7 +35,7 @@ export const Logout: React.FC = ({ authResource, onComplete }) => { setTimeout(onComplete, DISPLAY_DELAY_MS); }; run(); - }, [authResource, onComplete]); + }, [authResource, onComplete, storage]); if (!done) { return null; diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index ae653cb..028509e 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -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 = ({ onComplete }) => { +export const AuthStatus: React.FC = ({ + authStorage = defaultStorage, + onComplete, +}) => { + const storage = authStorage; const [checked, setChecked] = useState(false); const [authenticated, setAuthenticated] = useState(false); const [tokenPreview, setTokenPreview] = useState(''); @@ -26,7 +31,7 @@ export const AuthStatus: React.FC = ({ onComplete }) => { setCredentialsPath(credentialsPath); setChecked(true); setTimeout(onComplete, DISPLAY_DELAY_MS); - }, [onComplete]); + }, [onComplete, storage]); if (!checked) { return null; diff --git a/packages/cli/src/commands/demo/demo-runner.tsx b/packages/cli/src/commands/demo/demo-runner.tsx index bb24b16..925672c 100644 --- a/packages/cli/src/commands/demo/demo-runner.tsx +++ b/packages/cli/src/commands/demo/demo-runner.tsx @@ -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'; @@ -28,6 +29,7 @@ interface DemoRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + authStorage?: AuthStorage; paymentMethodId?: string; onlyCard?: boolean; onlySpt?: boolean; @@ -38,11 +40,13 @@ export const DemoRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + authStorage = defaultStorage, paymentMethodId: preselectedPmId, onlyCard, onlySpt, onComplete, }) => { + const storage = authStorage; const preselected = onlyCard ? 'card' : onlySpt ? 'spt' : null; const [choice, setChoice] = useState(preselected); const [menuIndex, setMenuIndex] = useState(0); @@ -113,6 +117,7 @@ export const DemoRunner: React.FC = ({ setPhase(postAuthPhase)} /> )} diff --git a/packages/cli/src/commands/demo/index.tsx b/packages/cli/src/commands/demo/index.tsx index e190415..08200a1 100644 --- a/packages/cli/src/commands/demo/index.tsx +++ b/packages/cli/src/commands/demo/index.tsx @@ -1,4 +1,5 @@ import type { + AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -23,6 +24,7 @@ export function createDemoCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + authStorage?: AuthStorage, ) { return Cli.create('demo', { description: @@ -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()} diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index 9d5642b..f9429c6 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -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'; @@ -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', }); @@ -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; diff --git a/packages/cli/src/commands/onboard/index.tsx b/packages/cli/src/commands/onboard/index.tsx index 8183ce5..a39d831 100644 --- a/packages/cli/src/commands/onboard/index.tsx +++ b/packages/cli/src/commands/onboard/index.tsx @@ -1,4 +1,5 @@ import type { + AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; @@ -12,6 +13,7 @@ export function createOnboardCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, + authStorage?: AuthStorage, ) { return Cli.create('onboard', { description: @@ -33,6 +35,7 @@ export function createOnboardCli( authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + authStorage={authStorage} onComplete={() => unmount()} />, ); diff --git a/packages/cli/src/commands/onboard/onboard-runner.tsx b/packages/cli/src/commands/onboard/onboard-runner.tsx index 1040c7e..b5a40d0 100644 --- a/packages/cli/src/commands/onboard/onboard-runner.tsx +++ b/packages/cli/src/commands/onboard/onboard-runner.tsx @@ -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 { useEffect, useRef, useState } from 'react'; @@ -17,6 +18,7 @@ interface OnboardRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; + authStorage?: AuthStorage; onComplete: () => void; } @@ -24,8 +26,10 @@ export const OnboardRunner: React.FC = ({ authRepo, spendRequestRepo, paymentMethodsResource, + authStorage = defaultStorage, onComplete, }) => { + const storage = authStorage; const [phase, setPhase] = useState('welcome'); const [authSkipped, setAuthSkipped] = useState(false); const [pmMissing, setPmMissing] = useState(false); @@ -121,6 +125,7 @@ export const OnboardRunner: React.FC = ({ authResolver.current?.()} /> ) : null} @@ -163,6 +168,7 @@ export const OnboardRunner: React.FC = ({ authRepo={authRepo} spendRequestRepo={spendRequestRepo} paymentMethodsResource={paymentMethodsResource} + authStorage={storage} onComplete={onComplete} /> diff --git a/packages/cli/src/commands/payment-methods/index.tsx b/packages/cli/src/commands/payment-methods/index.tsx index 55e49b9..bba9094 100644 --- a/packages/cli/src/commands/payment-methods/index.tsx +++ b/packages/cli/src/commands/payment-methods/index.tsx @@ -1,4 +1,4 @@ -import type { IPaymentMethodsResource } from '@stripe/link-sdk'; +import type { AuthStorage, IPaymentMethodsResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import { render } from 'ink'; import React from 'react'; @@ -8,6 +8,7 @@ import { PaymentMethodsList } from './list'; export function createPaymentMethodsCli( createResource: () => IPaymentMethodsResource, + authStorage?: AuthStorage, ) { const cli = Cli.create('payment-methods', { description: 'Payment methods management commands', @@ -16,7 +17,7 @@ export function createPaymentMethodsCli( cli.command('list', { description: 'List all payment methods on your account', outputPolicy: 'agent-only' as const, - middleware: [requireAuth], + middleware: [requireAuth(authStorage)], async run(c) { const resource = createResource(); @@ -38,7 +39,7 @@ export function createPaymentMethodsCli( cli.command('add', { description: 'Open the Link wallet to add a new payment method', outputPolicy: 'agent-only' as const, - middleware: [requireAuth], + middleware: [requireAuth(authStorage)], async run(c) { if (!c.agent && !c.formatExplicit) { return new Promise((resolve) => { diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index e2cc03e..72a321b 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -1,4 +1,5 @@ import type { + AuthStorage, CredentialType, ISpendRequestResource, LineItem, @@ -44,7 +45,10 @@ async function applyOutputFile( } as SpendRequest & { card_output_file?: string }; } -export function createSpendRequestCli(repository: ISpendRequestResource) { +export function createSpendRequestCli( + repository: ISpendRequestResource, + authStorage?: AuthStorage, +) { const cli = Cli.create('spend-request', { description: 'Spend request management commands', }); @@ -55,7 +59,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { alias: { merchantName: 'm' }, outputPolicy: 'agent-only' as const, async *run(c) { - requireAuthGuard(c); + requireAuthGuard(c, authStorage); const opts = c.options; const requestApproval = !!opts.requestApproval; @@ -182,7 +186,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { }), options: updateOptions, outputPolicy: 'agent-only' as const, - middleware: [requireAuth], + middleware: [requireAuth(authStorage)], async run(c) { const id = c.args.id; const opts = c.options; @@ -235,7 +239,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { }), outputPolicy: 'agent-only' as const, async *run(c) { - requireAuthGuard(c); + requireAuthGuard(c, authStorage); const id = c.args.id; @@ -279,7 +283,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { options: retrieveOptions, outputPolicy: 'agent-only' as const, async *run(c) { - requireAuthGuard(c); + requireAuthGuard(c, authStorage); const id = c.args.id; const opts = c.options; @@ -381,7 +385,7 @@ export function createSpendRequestCli(repository: ISpendRequestResource) { id: z.string().describe('Spend request ID'), }), outputPolicy: 'agent-only' as const, - middleware: [requireAuth], + middleware: [requireAuth(authStorage)], async run(c) { const id = c.args.id; diff --git a/packages/cli/src/utils/require-auth.ts b/packages/cli/src/utils/require-auth.ts index 8ec25f7..7d6eea8 100644 --- a/packages/cli/src/utils/require-auth.ts +++ b/packages/cli/src/utils/require-auth.ts @@ -1,4 +1,5 @@ -import { storage } from '@stripe/link-sdk'; +import type { AuthStorage } from '@stripe/link-sdk'; +import { storage as defaultStorage } from '@stripe/link-sdk'; import type { MiddlewareHandler } from 'incur'; interface AuthErrorOptions { @@ -15,29 +16,22 @@ export const NOT_AUTHENTICATED_ERROR: AuthErrorOptions = { }, }; -/** - * Incur middleware that short-circuits with NOT_AUTHENTICATED if no auth tokens exist. - * Use via `middleware: [requireAuth]` on command definitions. - * - * NOTE: Due to an incur limitation, this cannot be used on `async *run` (generator) - * commands that call `c.error()` within the generator body. For those, use - * `requireAuthGuard(c)` inside the handler instead. - */ -export const requireAuth: MiddlewareHandler = (c, next) => { - if (!storage.isAuthenticated()) { - return c.error(NOT_AUTHENTICATED_ERROR); - } - return next(); -}; +export function requireAuth(authStorage?: AuthStorage): MiddlewareHandler { + const store = authStorage ?? defaultStorage; + return (c, next) => { + if (!store.isAuthenticated()) { + return c.error(NOT_AUTHENTICATED_ERROR); + } + return next(); + }; +} -/** - * Inline auth guard for generator commands where middleware doesn't work. - * Call at the top of `async *run(c)` handlers. - */ -export function requireAuthGuard(c: { - error: (err: AuthErrorOptions) => never; -}) { - if (!storage.isAuthenticated()) { +export function requireAuthGuard( + c: { error: (err: AuthErrorOptions) => never }, + authStorage?: AuthStorage, +) { + const store = authStorage ?? defaultStorage; + if (!store.isAuthenticated()) { c.error(NOT_AUTHENTICATED_ERROR); } } diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 503d2a9..d494263 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,4 +1,5 @@ import { + type AuthStorage, type IPaymentMethodsResource, type IShippingAddressResource, type ISpendRequestResource, @@ -15,11 +16,13 @@ import type { IAuthResource } from '../auth/types'; interface ResourceFactoryOptions { verbose?: boolean; defaultHeaders?: Record; + authStorage?: AuthStorage; } export class ResourceFactory { private readonly verbose: boolean; private readonly defaultHeaders?: Record; + private readonly authStorage?: AuthStorage; private authResource?: IAuthResource; private accessTokenProvider?: ReturnType; private spendRequestResource?: ISpendRequestResource; @@ -30,6 +33,7 @@ export class ResourceFactory { constructor(options: ResourceFactoryOptions = {}) { this.verbose = options.verbose ?? false; this.defaultHeaders = options.defaultHeaders; + this.authStorage = options.authStorage; } createAuthResource(): IAuthResource { @@ -45,6 +49,10 @@ export class ResourceFactory { return this.authResource; } + getAuthStorage(): AuthStorage | undefined { + return this.authStorage; + } + private createSdkAccessTokenProvider() { if (this.accessTokenProvider) { return this.accessTokenProvider; @@ -52,6 +60,7 @@ export class ResourceFactory { this.accessTokenProvider = createAccessTokenProvider( this.createAuthResource(), + this.authStorage, ); return this.accessTokenProvider; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 81f5217..3aedc75 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,5 +9,9 @@ export * from './resources/spend-request'; export * from './resources/payment-methods'; export * from './resources/shipping-address'; export * from './resources/user-info'; -export { MemoryStorage, storage } from './utils/storage'; -export type { AuthStorage, PendingDeviceAuth } from './utils/storage'; +export { MemoryStorage, Storage, storage } from './utils/storage'; +export type { + AuthStorage, + PendingDeviceAuth, + StorageOptions, +} from './utils/storage'; diff --git a/packages/sdk/src/utils/__tests__/storage.test.ts b/packages/sdk/src/utils/__tests__/storage.test.ts index 70dcc54..e04b946 100644 --- a/packages/sdk/src/utils/__tests__/storage.test.ts +++ b/packages/sdk/src/utils/__tests__/storage.test.ts @@ -104,6 +104,41 @@ describePosix('Storage (disk-backed) file permissions', () => { expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); }); + it('configPath option writes to the specified file path', () => { + const customPath = path.join(tmpDir, 'custom-creds.json'); + const storage = new Storage({ configPath: customPath }); + + storage.setAuth({ + access_token: 'at_custom', + refresh_token: 'rt_custom', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(storage.getPath()).toBe(customPath); + expect(fs.existsSync(customPath)).toBe(true); + expect(storage.getAuth()?.access_token).toBe('at_custom'); + + const mode = fs.statSync(customPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('configPath takes precedence over cwd', () => { + const customPath = path.join(tmpDir, 'override.json'); + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-other-')); + const storage = new Storage({ configPath: customPath, cwd: otherDir }); + + storage.setAuth({ + access_token: 'at_override', + refresh_token: 'rt_override', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(storage.getPath()).toBe(customPath); + fs.rmSync(otherDir, { recursive: true, force: true }); + }); + it('also restricts pendingDeviceAuth, which is written to the same file', () => { const storage = new Storage({ cwd: tmpDir }); diff --git a/packages/sdk/src/utils/storage.ts b/packages/sdk/src/utils/storage.ts index cf8ee53..2d38cad 100644 --- a/packages/sdk/src/utils/storage.ts +++ b/packages/sdk/src/utils/storage.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import path from 'node:path'; import type { AuthTokens } from '@/types/index'; import Conf from 'conf'; @@ -48,6 +49,9 @@ export interface StorageOptions { // conf resolves to the platform user-config directory. Tests pass a temp // dir so they don't touch the real location. cwd?: string; + // Full file path for the credential file. When set, takes precedence over + // cwd. The file is split into directory + config name for conf. + configPath?: string; } export class Storage implements AuthStorage { @@ -60,10 +64,20 @@ export class Storage implements AuthStorage { private getConfig(): Conf { if (!this.config) { + let locationOverride: { cwd: string; configName?: string } | undefined; + if (this.options.configPath) { + const parsed = path.parse(path.resolve(this.options.configPath)); + // conf appends `.json` to configName, so strip it to avoid double extension + const configName = parsed.ext === '.json' ? parsed.name : parsed.base; + locationOverride = { cwd: parsed.dir, configName }; + } else if (this.options.cwd) { + locationOverride = { cwd: this.options.cwd }; + } + this.config = new Conf({ projectName: 'link-cli', configFileMode: CONFIG_FILE_MODE, - ...(this.options.cwd ? { cwd: this.options.cwd } : {}), + ...locationOverride, defaults: { auth: null, pendingDeviceAuth: null, diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 94f0c29..6abbf82 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -64,6 +64,7 @@ Call `tools/list` to see all available MCP tools. - Multi-step commands return a `_next` action. For example, authenticating or creating a spend request returns a `_next.command` that must be run to complete the flow. - By default all output is in `toon` format. Pass `--format [json|md|yaml]` to change output format. - Some commands return a verification or approval URL. **These** must be presented to the user clearly for their action. +- `--auth ` flag 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. Example: `link-cli auth login --auth credentials.json` _Recommended_: Run `link-cli --llms` to understand all the available commands. The `--llms-full` output is the canonical reference for parameter names, types, and valid values. Pass `--schema` before invoking a command to understand its parameters and constraints.