Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.

This commit is contained in:
2026-04-20 10:30:38 +00:00
parent df4f438f6d
commit ff2fe126c6
44 changed files with 8220 additions and 1503 deletions

View File

@@ -16,6 +16,8 @@ import { colors, logo } from '../theme.js';
import fs from 'fs';
import path from 'path';
import { createMnemonicFile } from '../../cli/mnemonic.js';
import { getMnemonicsDir } from '../../utils/paths.js';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic } from '@bitauth/libauth';
@@ -39,33 +41,41 @@ interface MnemonicFileEntry {
* Focus sections the user can tab between.
* When saved wallets exist the file list is shown first.
*/
type FocusSection = 'files' | 'input' | 'button';
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
/**
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL,
* and converts the entropy back to a BIP39 English mnemonic phrase.
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
*/
function loadMnemonicFiles(): MnemonicFileEntry[] {
const cwd = process.cwd();
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-'));
const dirs = [getMnemonicsDir(), process.cwd()];
const seenBasenames = new Set<string>();
const entries: MnemonicFileEntry[] = [];
for (const filename of filenames) {
try {
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim();
const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject();
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const filenames = fs
.readdirSync(dir)
.filter((f) => f.startsWith('mnemonic-'));
for (const filename of filenames) {
if (seenBasenames.has(filename)) continue;
try {
const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim();
const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject();
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
if (typeof mnemonicResult === 'string') continue;
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
if (typeof mnemonicResult === 'string') continue;
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
const label = raw.comment
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
const label = raw.comment
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
} catch {
// Skip files that can't be parsed
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
seenBasenames.add(filename);
} catch {
// Skip files that can't be parsed
}
}
}
return entries;
@@ -91,6 +101,9 @@ export function SeedInputScreen(): React.ReactElement {
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
// Focus: when saved wallets exist default to the file list, otherwise the input.
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
@@ -104,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
* The ordered list of focusable sections (files section only when entries exist).
*/
const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'button']
: ['input', 'button'];
? ['files', 'input', 'saveCheckbox', 'button']
: ['input', 'saveCheckbox', 'button'];
/**
* Shows a status message with the given type.
@@ -118,28 +131,46 @@ export function SeedInputScreen(): React.ReactElement {
/**
* Shared wallet initialization handler used by both manual entry and file selection.
*/
const doInitialize = useCallback(async (seed: string) => {
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
const doInitialize = useCallback(
async (seed: string, options?: { saveMnemonic?: boolean }) => {
showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...');
setIsSubmitting(true);
try {
await initializeWallet(seed);
try {
await initializeWallet(seed);
showStatus('Wallet initialized successfully!', 'success');
setStatus('Wallet ready');
setSeedPhrase('');
let statusText = 'Wallet initialized successfully!';
if (options?.saveMnemonic) {
try {
const savedAs = createMnemonicFile(getMnemonicsDir(), seed);
setMnemonicFiles(loadMnemonicFiles());
statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`;
} catch (saveErr) {
const saveMsg =
saveErr instanceof Error ? saveErr.message : String(saveErr);
statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`;
}
}
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
}, [initializeWallet, navigate, showStatus, setStatus]);
showStatus(statusText, 'success');
setStatus('Wallet ready');
setSeedPhrase('');
setSaveMnemonicChecked(false);
setTimeout(() => {
navigate('wallet');
}, 500);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error');
setStatus('Initialization failed');
setIsSubmitting(false);
}
},
[initializeWallet, navigate, showStatus, setStatus],
);
/**
* Handles manual seed phrase submission with validation.
@@ -158,8 +189,8 @@ export function SeedInputScreen(): React.ReactElement {
return;
}
await doInitialize(seed);
}, [seedPhrase, doInitialize, showStatus]);
await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
}, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
/**
* Handles selecting a mnemonic file from the list.
@@ -186,6 +217,14 @@ export function SeedInputScreen(): React.ReactElement {
return;
}
// Space or Enter toggles "save mnemonic" when that row is focused
if (focusedSection === 'saveCheckbox') {
if (_input === ' ' || key.return) {
setSaveMnemonicChecked((v) => !v);
return;
}
}
// Arrow keys inside the file list
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
if (key.upArrow) {
@@ -319,6 +358,32 @@ export function SeedInputScreen(): React.ReactElement {
/>
</Box>
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
<Box
marginTop={1}
paddingX={1}
borderStyle='single'
borderColor={
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
}
>
<Text
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
bold={focusedSection === 'saveCheckbox'}
>
{saveMnemonicChecked ? '[x] ' : '[ ] '}
</Text>
<Text color={colors.text}>Save this mnemonic</Text>
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
</Box>
{focusedSection === 'saveCheckbox' && (
<Box marginTop={0} paddingX={1}>
<Text color={colors.textMuted} dimColor>
Space / Enter: toggle
</Text>
</Box>
)}
{/* Status message */}
<Box marginTop={1} height={1}>
{statusMessage && (
@@ -345,7 +410,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */}
<Box marginTop={2}>
<Text color={colors.textMuted} dimColor>
Tab: navigate sections Enter: submit Esc: back
Tab: navigate Enter: submit, load wallet, or toggle save Space: toggle save Esc: back
</Text>
</Box>
</Box>