Skip to content

Commit 5734e07

Browse files
katspaughclaude
andauthored
feat: add non-interactive support for transaction commands (Phase 4) (#33)
* feat: add non-interactive support for transaction commands Add JSON output and password handling to transaction commands for automation support: Transaction Read Commands (JSON output): - tx list: Added --json output with transaction filtering - tx status: Added --json output with comprehensive tx data Transaction Write Commands (Password + JSON): - tx sign: Integrated getPassword utility, added JSON output - tx execute: Integrated getPassword utility, added JSON output Key Changes: - All commands check isNonInteractiveMode() and skip prompts/spinners - Error handling migrated to outputError with proper exit codes - Password input via getPassword (env var, file, or flag) - Consistent JSON output format across all tx commands - Non-interactive mode requires safeTxHash argument Related to Phase 4 implementation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * fix: replace updatedAt with executedAt in tx status JSON output StoredTransaction type has executedAt field, not updatedAt. This fixes the TypeScript compilation error in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent fed6301 commit 5734e07

File tree

4 files changed

+334
-208
lines changed

4 files changed

+334
-208
lines changed

src/commands/tx/execute.ts

Lines changed: 76 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ import {
99
ensureActiveWallet,
1010
ensureChainConfigured,
1111
handleCommandError,
12-
promptPassword,
12+
isNonInteractiveMode,
13+
outputSuccess,
14+
outputError,
1315
} from '../../utils/command-helpers.js'
1416
import { selectTransaction } from '../../utils/safe-helpers.js'
17+
import { getPassword } from '../../utils/password-handler.js'
18+
import { getGlobalOptions } from '../../types/global-options.js'
19+
import { ExitCode } from '../../constants/exit-codes.js'
1520

1621
export async function executeTransaction(safeTxHash?: string) {
17-
p.intro('Execute Safe Transaction')
22+
if (!isNonInteractiveMode()) {
23+
p.intro('Execute Safe Transaction')
24+
}
1825

1926
try {
2027
const ctx = createCommandContext()
@@ -26,6 +33,9 @@ export async function executeTransaction(safeTxHash?: string) {
2633
let selectedSafeTxHash = safeTxHash
2734

2835
if (!selectedSafeTxHash) {
36+
if (isNonInteractiveMode()) {
37+
outputError('Transaction hash is required in non-interactive mode', ExitCode.INVALID_ARGS)
38+
}
2939
const hash = await selectTransaction(
3040
ctx.transactionStore,
3141
ctx.safeStorage,
@@ -39,38 +49,30 @@ export async function executeTransaction(safeTxHash?: string) {
3949

4050
const transaction = ctx.transactionStore.getTransaction(selectedSafeTxHash)
4151
if (!transaction) {
42-
p.log.error(`Transaction ${selectedSafeTxHash} not found`)
43-
p.outro('Failed')
44-
return
52+
outputError(`Transaction ${selectedSafeTxHash} not found`, ExitCode.ERROR)
4553
}
4654

4755
if (transaction.status === TransactionStatus.EXECUTED) {
48-
p.log.error('Transaction already executed')
49-
p.outro('Failed')
50-
return
56+
outputError('Transaction already executed', ExitCode.ERROR)
5157
}
5258

5359
if (transaction.status === 'rejected') {
54-
p.log.error('Transaction has been rejected')
55-
p.outro('Failed')
56-
return
60+
outputError('Transaction has been rejected', ExitCode.ERROR)
5761
}
5862

5963
// Get Safe info
6064
const safe = ctx.safeStorage.getSafe(transaction.chainId, transaction.safeAddress)
6165
if (!safe) {
62-
p.log.error('Safe not found')
63-
p.outro('Failed')
64-
return
66+
outputError('Safe not found', ExitCode.SAFE_NOT_FOUND)
6567
}
6668

6769
// Get chain
6870
const chain = ensureChainConfigured(transaction.chainId, ctx.configStore)
6971
if (!chain) return
7072

7173
// Fetch live owners and threshold from blockchain
72-
const spinner = p.spinner()
73-
spinner.start('Fetching Safe information from blockchain...')
74+
const spinner = !isNonInteractiveMode() ? p.spinner() : null
75+
spinner?.start('Fetching Safe information from blockchain...')
7476

7577
let owners: Address[]
7678
let threshold: number
@@ -80,65 +82,71 @@ export async function executeTransaction(safeTxHash?: string) {
8082
txService.getOwners(transaction.safeAddress),
8183
txService.getThreshold(transaction.safeAddress),
8284
])
83-
spinner.stop('Safe information fetched')
85+
spinner?.stop('Safe information fetched')
8486
} catch (error) {
85-
spinner.stop('Failed to fetch Safe information')
86-
p.log.error(
87-
error instanceof Error ? error.message : 'Failed to fetch Safe data from blockchain'
87+
spinner?.stop('Failed to fetch Safe information')
88+
outputError(
89+
error instanceof Error ? error.message : 'Failed to fetch Safe data from blockchain',
90+
ExitCode.NETWORK_ERROR
8891
)
89-
p.outro('Failed')
90-
return
9192
}
9293

9394
// Check if wallet is an owner
9495
if (!owners.some((owner) => owner.toLowerCase() === activeWallet.address.toLowerCase())) {
95-
p.log.error('Active wallet is not an owner of this Safe')
96-
p.outro('Failed')
97-
return
96+
outputError('Active wallet is not an owner of this Safe', ExitCode.ERROR)
9897
}
9998

10099
// Check if we have enough signatures
101100
const sigCount = transaction.signatures?.length || 0
102101
if (sigCount < threshold) {
103-
p.log.error(`Not enough signatures. Have ${sigCount}, need ${threshold}`)
104-
p.outro('Failed')
105-
return
102+
outputError(`Not enough signatures. Have ${sigCount}, need ${threshold}`, ExitCode.ERROR)
106103
}
107104

108-
// Display transaction details
109-
console.log('\nTransaction Details:')
110-
console.log(` To: ${transaction.metadata.to}`)
111-
console.log(` Value: ${transaction.metadata.value} wei`)
112-
console.log(` Data: ${transaction.metadata.data}`)
113-
console.log(` Operation: ${transaction.metadata.operation === 0 ? 'Call' : 'DelegateCall'}`)
114-
console.log(` Signatures: ${sigCount}/${threshold}`)
115-
116-
const confirm = await p.confirm({
117-
message: 'Execute this transaction on-chain?',
118-
initialValue: false,
119-
})
120-
121-
if (!confirm || p.isCancel(confirm)) {
122-
p.cancel('Operation cancelled')
123-
return
105+
if (!isNonInteractiveMode()) {
106+
// Display transaction details
107+
console.log('\nTransaction Details:')
108+
console.log(` To: ${transaction.metadata.to}`)
109+
console.log(` Value: ${transaction.metadata.value} wei`)
110+
console.log(` Data: ${transaction.metadata.data}`)
111+
console.log(` Operation: ${transaction.metadata.operation === 0 ? 'Call' : 'DelegateCall'}`)
112+
console.log(` Signatures: ${sigCount}/${threshold}`)
113+
114+
const confirm = await p.confirm({
115+
message: 'Execute this transaction on-chain?',
116+
initialValue: false,
117+
})
118+
119+
if (!confirm || p.isCancel(confirm)) {
120+
p.cancel('Operation cancelled')
121+
return
122+
}
124123
}
125124

126-
// Request password
127-
const password = await promptPassword(false, 'Enter wallet password')
128-
if (!password) return
125+
// Request password using centralized handler
126+
const globalOptions = getGlobalOptions()
127+
const password = await getPassword(
128+
{
129+
password: globalOptions.password,
130+
passwordFile: globalOptions.passwordFile,
131+
passwordEnv: 'SAFE_WALLET_PASSWORD',
132+
},
133+
'Enter wallet password'
134+
)
135+
136+
if (!password) {
137+
outputError('Password is required', ExitCode.AUTH_FAILURE)
138+
}
129139

130140
// Get private key
131-
const spinner2 = p.spinner()
132-
spinner2.start('Executing transaction')
141+
const spinner2 = !isNonInteractiveMode() ? p.spinner() : null
142+
spinner2?.start('Executing transaction')
133143

134144
let privateKey: string
135145
try {
136146
privateKey = ctx.walletStorage.getPrivateKey(activeWallet.id, password)
137147
} catch {
138-
spinner2.stop('Failed')
139-
p.log.error('Invalid password')
140-
p.outro('Failed')
141-
return
148+
spinner2?.stop('Failed')
149+
outputError('Invalid password', ExitCode.AUTH_FAILURE)
142150
}
143151

144152
// Execute transaction
@@ -156,14 +164,24 @@ export async function executeTransaction(safeTxHash?: string) {
156164
// Update transaction status
157165
ctx.transactionStore.updateStatus(selectedSafeTxHash, TransactionStatus.EXECUTED, txHash)
158166

159-
spinner2.stop('Transaction executed')
167+
spinner2?.stop('Transaction executed')
160168

161169
const explorerUrl = chain.explorer ? `${chain.explorer}/tx/${txHash}` : undefined
162170

163-
await renderScreen(TransactionExecuteSuccessScreen, {
164-
txHash,
165-
explorerUrl,
166-
})
171+
if (isNonInteractiveMode()) {
172+
outputSuccess('Transaction executed successfully', {
173+
safeTxHash: selectedSafeTxHash,
174+
txHash,
175+
explorerUrl,
176+
chainId: transaction.chainId,
177+
chainName: chain.name,
178+
})
179+
} else {
180+
await renderScreen(TransactionExecuteSuccessScreen, {
181+
txHash,
182+
explorerUrl,
183+
})
184+
}
167185
} catch (error) {
168186
handleCommandError(error)
169187
}

src/commands/tx/list.ts

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { TransactionStatus } from '../../types/transaction.js'
77
import { formatSafeAddress } from '../../utils/eip3770.js'
88
import { renderScreen } from '../../ui/render.js'
99
import { TransactionListScreen } from '../../ui/screens/index.js'
10+
import { isNonInteractiveMode, outputSuccess } from '../../utils/command-helpers.js'
1011

1112
/**
1213
* Lists Safe transactions with optional filtering by Safe and status.
@@ -33,10 +34,14 @@ export async function listTransactions(account?: string, statusFilter?: Transact
3334

3435
// Check if we have any transactions at all
3536
if (transactions.length === 0) {
36-
// Show empty state screen
37-
await renderScreen(TransactionListScreen, {
38-
statusFilter,
39-
})
37+
if (isNonInteractiveMode()) {
38+
outputSuccess('No transactions found', { transactions: [] })
39+
} else {
40+
// Show empty state screen
41+
await renderScreen(TransactionListScreen, {
42+
statusFilter,
43+
})
44+
}
4045
return
4146
}
4247

@@ -66,9 +71,28 @@ export async function listTransactions(account?: string, statusFilter?: Transact
6671

6772
if (safes.length === 0) {
6873
// No Safes, show all transactions
69-
await renderScreen(TransactionListScreen, {
70-
statusFilter,
71-
})
74+
if (isNonInteractiveMode()) {
75+
// Apply status filter if specified
76+
const filtered = statusFilter
77+
? transactions.filter((tx) => tx.status === statusFilter)
78+
: transactions
79+
outputSuccess('Transactions retrieved', {
80+
total: filtered.length,
81+
transactions: filtered.map((tx) => ({
82+
safeTxHash: tx.safeTxHash,
83+
safeAddress: tx.safeAddress,
84+
chainId: tx.chainId,
85+
status: tx.status,
86+
metadata: tx.metadata,
87+
signatures: tx.signatures,
88+
createdAt: tx.createdAt,
89+
})),
90+
})
91+
} else {
92+
await renderScreen(TransactionListScreen, {
93+
statusFilter,
94+
})
95+
}
7296
return
7397
}
7498

@@ -112,12 +136,47 @@ export async function listTransactions(account?: string, statusFilter?: Transact
112136
}
113137
}
114138

115-
// Render the TransactionListScreen with the selected filters
116-
await renderScreen(TransactionListScreen, {
117-
safeAddress: filterSafeAddress || undefined,
118-
chainId: filterChainId || undefined,
119-
statusFilter,
120-
})
139+
// Apply filters
140+
let filtered = transactions
141+
if (filterSafeAddress) {
142+
filtered = filtered.filter(
143+
(tx) => tx.safeAddress.toLowerCase() === filterSafeAddress!.toLowerCase()
144+
)
145+
}
146+
if (filterChainId) {
147+
filtered = filtered.filter((tx) => tx.chainId === filterChainId)
148+
}
149+
if (statusFilter) {
150+
filtered = filtered.filter((tx) => tx.status === statusFilter)
151+
}
152+
153+
// Output in JSON mode or render screen
154+
if (isNonInteractiveMode()) {
155+
outputSuccess('Transactions retrieved', {
156+
total: filtered.length,
157+
filters: {
158+
safeAddress: filterSafeAddress || undefined,
159+
chainId: filterChainId || undefined,
160+
status: statusFilter || undefined,
161+
},
162+
transactions: filtered.map((tx) => ({
163+
safeTxHash: tx.safeTxHash,
164+
safeAddress: tx.safeAddress,
165+
chainId: tx.chainId,
166+
status: tx.status,
167+
metadata: tx.metadata,
168+
signatures: tx.signatures,
169+
createdAt: tx.createdAt,
170+
})),
171+
})
172+
} else {
173+
// Render the TransactionListScreen with the selected filters
174+
await renderScreen(TransactionListScreen, {
175+
safeAddress: filterSafeAddress || undefined,
176+
chainId: filterChainId || undefined,
177+
statusFilter,
178+
})
179+
}
121180
} catch (error) {
122181
p.log.error(`Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`)
123182
p.outro('Failed')

0 commit comments

Comments
 (0)