diff --git a/package.json b/package.json index a970123..dca1675 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "prettier": "^3.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", + "tsx": "^4.21.0", "zod": "^4.3.6" }, "devDependencies": { @@ -56,7 +57,6 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.1.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.2" } diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index f7c0a3a..48c9df1 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -453,7 +453,7 @@ export const handleInvitationCommand = async ( // Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session const invitationInstance = await deps.app.createInvitation(rawInvitation); deps.io.verbose( - `Invitation created: ${formatObject(invitationInstance.data)}`, + `Invitation instance created: ${formatObject(invitationInstance.data)}`, ); // Read the variables that were passed in via `-var- ` @@ -474,6 +474,8 @@ export const handleInvitationCommand = async ( // Append the inputs and outputs to the invitation const { inputs, outputs } = params; + deps.io.verbose(`Inputs: ${formatObject(inputs)}`); + deps.io.verbose(`Outputs: ${formatObject(outputs)}`); if (inputs.length > 0 || outputs.length > 0) { await invitationInstance.append({ inputs, outputs }); } @@ -497,6 +499,9 @@ export const handleInvitationCommand = async ( hasMissingRequirements(missingRequirements.templateRequirements) || missingRequirements.inputsMissingSignatures.length > 0; + deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`); + deps.io.verbose(`Has missing requirements: ${hasMissing}`); + // If there are missing requirements, print them out if (hasMissing) { deps.io.out(`\n${bold("Remaining requirements:")}`); @@ -507,6 +512,9 @@ export const handleInvitationCommand = async ( options["sign"] === "true" || options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true"; + deps.io.verbose(`Should sign: ${shouldSign}`); + deps.io.verbose(`Should broadcast: ${shouldBroadcast}`); + // Sign the invitation if the user has requested it if (shouldSign) { await invitationInstance.sign(); diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts index ce0d4b4..964662d 100644 --- a/src/cli/commands/template.ts +++ b/src/cli/commands/template.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, writeFileSync } from "fs"; import path from "path"; import { generateTemplateIdentifier } from "@xo-cash/engine"; import type { XOTemplate } from "@xo-cash/types"; import { bold, dim, formatObject } from "../utils.js"; +import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js"; import { resolveTemplateReferences } from "../../utils/templates.js"; import type { CommandDependencies, CommandIO } from "./types.js"; import { CommandError } from "./types.js"; @@ -18,7 +19,7 @@ export const printTemplateHelp = (io: CommandIO): void => { ${bold("Usage:")} xo-cli template ${bold("Sub-commands:")} - - import ${dim("Import a template from a file")} + - import ${dim("Import a template from a JSON, JS, or TS file")} - list ${dim("List all templates")} - list ${dim("List all options of the field type in a template")} - inspect ${dim("Inspect a field in a template")} @@ -464,12 +465,26 @@ export const handleTemplateCommand = async ( ); } - // Read the template file - const template = await readFileSync(templatePath, "utf8"); + // Read and load the template file (JSON directly, TS/JS via child process). + let templateContents: string; + try { + templateContents = await loadTemplateFromFile(templatePath); + } catch (error) { + const message = + error instanceof TemplateLoadError + ? error.message + : error instanceof Error + ? error.message + : String(error); + deps.io.err(message); + printTemplateHelp(deps.io); + throw new CommandError("template.import.load_failed", message); + } + deps.io.verbose(`Importing template: ${templateFile}`); // Import the template - await deps.app.engine.importTemplate(template); + await deps.app.engine.importTemplate(templateContents); deps.io.verbose(`Template imported: ${templateFile}`); // Return the template file diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 490bcbe..ddacfd6 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -625,34 +625,26 @@ export class Invitation extends EventEmitter { ); } - console.dir(this.data, { depth: null }); - // Create a list of all the variables from the commits const variables = this.data.commits.flatMap( (c) => c.data?.variables ?? [], ); - console.dir(variables, { depth: null }); // Create a dictionary of the variables const formattedVariables = variables.reduce( (acc, v) => { const { variableIdentifier, value } = v; - console.log(typeof value); acc[variableIdentifier ?? ""] = value; return acc; }, {} as Record, ); - console.dir(formattedVariables, { depth: null }); - // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) const valueSatoshis = compileCashAssemblyString( { cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, ); - console.dir(valueSatoshis, { depth: null }); - // Return the value satoshis as a bigint // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression return BigInt(valueSatoshis); @@ -707,11 +699,9 @@ export class Invitation extends EventEmitter { for (const output of outputs) { if (typeof output === "string") { const sats = await this.getSatsOut(output); - console.log(`Sats for output: ${output} is ${sats}`); totalSats += sats } else { const sats = await this.getSatsOut(output.output); - console.log(`Sats for output: ${output.output} is ${sats}`); totalSats += sats; } } diff --git a/src/templates/wrap-template.ts b/src/templates/wrap-template.ts new file mode 100644 index 0000000..dc1befd --- /dev/null +++ b/src/templates/wrap-template.ts @@ -0,0 +1,266 @@ +import type { XOTemplate } from '@xo-cash/types'; + +export const wrapBCHTemplate: XOTemplate = { + $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', + + name: 'Wrapped BCH', + description: 'Convert between BCH and wBCH tokens.', + icon: 'wrap', + + version: '1', + supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], + + roles: { + user: { + name: 'User', + description: 'The person wrapping or unwrapping BCH.', + icon: 'user', + }, + }, + + start: [ + { + action: 'wrap', + role: 'user', + }, + { + action: 'unwrap', + role: 'user', + }, + ], + + actions: { + wrap: { + name: 'Wrap BCH', + description: 'Convert BCH into wBCH tokens.', + icon: 'wrap', + + roles: { + user: { + requirements: { + variables: ['amountToWrap', 'recipientLockingScript'], + }, + }, + }, + + requirements: { + participants: [{ role: 'user', slots: { min: 1, max: 1 } }], + }, + + transaction: 'wrapTransaction', + }, + + unwrap: { + name: 'Unwrap wBCH', + description: 'Convert wBCH tokens back into BCH.', + icon: 'unwrap', + + roles: { + user: { + requirements: { + variables: ['amountToUnwrap', 'recipientLockingScript'], + }, + }, + }, + + requirements: { + participants: [{ role: 'user', slots: { min: 1, max: 1 } }], + }, + + transaction: 'unwrapTransaction', + }, + }, + + transactions: { + wrapTransaction: { + name: 'Wrapped BCH', + description: 'Wrapped $( OP_DIV).$( OP_MOD) BCH into wBCH tokens.', + icon: 'wrap', + + inputs: [ + { input: 'covenantInput', inputIndex: 0 }, + ], + outputs: [ + { output: 'covenantOutput', outputIndex: 0 }, + { output: 'wrappedTokensOutput', outputIndex: undefined }, + ], + + version: 2, + locktime: 0, + composable: true, + }, + + unwrapTransaction: { + name: 'Unwrapped wBCH', + description: 'Unwrapped $( OP_DIV).$( OP_MOD) wBCH tokens back into BCH.', + icon: 'unwrap', + + inputs: [ + { input: 'covenantInput', inputIndex: 0 }, + ], + outputs: [ + { output: 'covenantOutput', outputIndex: 0 }, + { output: 'unwrappedSatoshisOutput', outputIndex: undefined }, + ], + + version: 2, + locktime: 0, + composable: true, + }, + }, + + outputs: { + covenantOutput: { + name: 'wBCH Covenant', + description: 'Holds BCH and wBCH tokens that can be freely converted.', + icon: 'contract', + + lockingScript: 'wrapBCHLockingScript', + }, + + wrappedTokensOutput: { + name: 'Wrapped wBCH', + description: 'Wrapped $( OP_DIV).$( OP_MOD) wBCH tokens.', + icon: 'receive', + + valueSatoshis: '$()', + token: { + category: '$()', + amount: '$()', + nft: null, + }, + + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + + lockingScript: '$()', + }, + + unwrappedSatoshisOutput: { + name: 'Unwrapped BCH', + description: 'Unwrapped $( OP_DIV).$( OP_MOD) BCH.', + icon: 'receive', + + valueSatoshis: '$()', + token: null, + + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + + lockingScript: '$()', + }, + }, + + inputs: { + covenantInput: { + name: 'wBCH Covenant', + description: 'The covenant being updated.', + icon: 'contract', + + unlockingScript: 'unlockCovenant', + }, + }, + + lockingScripts: { + wrapBCHLockingScript: { + name: 'wBCH Covenant', + description: 'Holds BCH and wBCH tokens that can be freely converted.', + icon: 'contract', + + lockingType: 'p2sh', + lockingBytecode: 'wrapBCHLockingBytecode', + + actions: [ + { action: 'wrap', role: 'user' }, + { action: 'unwrap', role: 'user' }, + ], + + state: { + variables: [], + secrets: [], + }, + balance: { + satoshis: 0n, + fungibleTokens: 0n, + }, + selectable: false, + }, + }, + + scripts: { + enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY', + enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY', + enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY', + + // Direct script references — introspection opcodes must not use $(...) evaluations + // because those are evaluated at compile time without transaction context. + wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved', + unlockCovenant: '', + }, + + constants: { + wbchTokenCategory: { + name: 'wBCH Token Category', + description: 'The official token category for Wrapped BCH.', + type: 'bytes', + value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3', + }, + satoshisPerBCH: { + name: 'Satoshis per BCH', + description: 'Used to display amounts in BCH with decimals.', + type: 'integer', + value: 100000000, + }, + tokenDust: { + name: 'Token Dust Limit', + description: 'Minimal satoshis required for a token-bearing output.', + type: 'integer', + value: 1000, + }, + }, + + variables: { + amountToWrap: { + name: 'Amount to Wrap', + description: 'How much BCH to convert to wBCH (in satoshis).', + type: 'integer', + hint: 'satoshis', + }, + amountToUnwrap: { + name: 'Amount to Unwrap', + description: 'How much wBCH to convert back to BCH (in satoshis).', + type: 'integer', + hint: 'satoshis', + }, + recipientLockingScript: { + name: 'Destination', + description: 'Where to receive your BCH or wBCH tokens.', + type: 'bytes', + hint: 'lockingScript', + }, + }, + + icons: [ + { name: 'wrap', hash: '0000000000000000000000' }, + { name: 'unwrap', hash: '0000000000000000000000' }, + { name: 'user', hash: '0000000000000000000000' }, + { name: 'contract', hash: '0000000000000000000000' }, + { name: 'receive', hash: '0000000000000000000000' }, + ], +}; diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx index 8a69845..f46e1d2 100644 --- a/src/tui/components/Dialog.tsx +++ b/src/tui/components/Dialog.tsx @@ -2,11 +2,17 @@ * Dialog components for modals, confirmations, and input dialogs. */ -import React, { useId, useRef, useState } from 'react'; -import { Box, Text, measureElement } from 'ink'; +import React, { useId, useMemo, useRef, useState } from 'react'; +import { Box, Text, measureElement, useStdout } from 'ink'; import TextInput from './TextInput.js'; import { colors } from '../theme.js'; import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js'; +import { + formatDialogMessageLines, + getMessageContentWidth, + getMessageDialogWidth, + MAX_MESSAGE_DIALOG_LINES, +} from '../utils/format-dialog-message.js'; /** * Base dialog wrapper props. @@ -261,6 +267,23 @@ export function MessageDialog({ isActive = true, }: MessageDialogProps): React.ReactElement { const layerId = useId(); + const { stdout } = useStdout(); + const dialogWidth = getMessageDialogWidth(stdout.columns ?? 80); + const contentWidth = getMessageContentWidth(dialogWidth); + + const messageLines = useMemo(() => { + const formattedLines = formatDialogMessageLines(message, contentWidth); + + if (formattedLines.length <= MAX_MESSAGE_DIALOG_LINES) { + return formattedLines; + } + + const hiddenLineCount = formattedLines.length - MAX_MESSAGE_DIALOG_LINES; + return [ + ...formattedLines.slice(0, MAX_MESSAGE_DIALOG_LINES), + `... and ${hiddenLineCount} more line(s)`, + ]; + }, [contentWidth, message]); // Auto-capture input when this dialog is mounted. useInputLayer(layerId); @@ -269,7 +292,7 @@ export function MessageDialog({ if (key.return || key.escape) { onClose(); } - }); + }, { isActive }); const borderColor = type === 'error' ? colors.error : type === 'success' ? colors.success : @@ -280,8 +303,16 @@ export function MessageDialog({ 'ℹ'; return ( - - {message} + + + {messageLines.map((line, index) => ( + {line} + ))} + Press Enter or Esc to close diff --git a/src/tui/components/FilePicker.tsx b/src/tui/components/FilePicker.tsx new file mode 100644 index 0000000..f3e5059 --- /dev/null +++ b/src/tui/components/FilePicker.tsx @@ -0,0 +1,273 @@ +/** + * Terminal file picker for browsing directories and selecting files. + * + * This component does not include a dialog wrapper — consumers wrap it in + * {@link DialogWrapper} when needed. When used inside a dialog overlay, pass + * `layerId` so keyboard input is routed through the input-layer stack instead + * of conflicting with background {@link ScrollableList} handlers. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, Text } from "ink"; + +import { ScrollableList, type ListItemData } from "./List.js"; +import { useLayeredInput } from "../hooks/useInputLayer.js"; +import { colors } from "../theme.js"; +import { + listDirectoryEntries, + type DirectoryEntry, +} from "../utils/list-directory-entries.js"; + +/** + * Props for {@link FilePicker}. + */ +export interface FilePickerProps { + /** Starting directory. Defaults to `process.cwd()`. */ + initialDirectory?: string; + /** + * Allowed file extensions without a leading dot (e.g. `['json']`). + * Omit to show all files. Directories are always shown. + */ + extensions?: string[]; + /** + * Input-layer id for dialog use. When set, this component handles ↑↓/Enter + * via {@link useLayeredInput} and disables {@link ScrollableList} focus. + */ + layerId?: string; + /** Whether the list receives keyboard focus when `layerId` is not set. */ + focus?: boolean; + /** Maximum visible rows in the scroll window. */ + maxVisible?: number; + /** Called when the user confirms a file with Enter. */ + onSelectFile: (absolutePath: string) => void; + /** Optional callback whenever the browsed directory changes. */ + onDirectoryChange?: (absolutePath: string) => void; +} + +/** + * Truncates a long path for display, keeping the end visible. + */ +function formatDirectoryPath(directoryPath: string, maxLength = 56): string { + if (directoryPath.length <= maxLength) { + return directoryPath; + } + + return `...${directoryPath.slice(-(maxLength - 3))}`; +} + +/** + * Builds list row metadata for a directory entry. + */ +function toListItem(entry: DirectoryEntry): ListItemData { + if (entry.kind === "parent") { + return { + key: "__parent__", + label: "..", + description: "Parent directory", + value: entry, + }; + } + + if (entry.kind === "directory") { + return { + key: `dir:${entry.absolutePath}`, + label: entry.name, + description: "Directory", + value: entry, + }; + } + + return { + key: `file:${entry.absolutePath}`, + label: entry.name, + value: entry, + }; +} + +/** + * Generic terminal file picker with optional extension filtering. + */ +export function FilePicker({ + initialDirectory = process.cwd(), + extensions, + layerId, + focus = true, + maxVisible = 10, + onSelectFile, + onDirectoryChange, +}: FilePickerProps): React.ReactElement { + const [currentDirectory, setCurrentDirectory] = useState(() => + initialDirectory, + ); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loadError, setLoadError] = useState(); + + const { entries, error } = useMemo( + () => listDirectoryEntries(currentDirectory, { extensions }), + [currentDirectory, extensions], + ); + + useEffect(() => { + setLoadError(error); + }, [error]); + + useEffect(() => { + setSelectedIndex(0); + }, [currentDirectory, extensions]); + + useEffect(() => { + if (entries.length === 0) { + setSelectedIndex(0); + return; + } + + if (selectedIndex >= entries.length) { + setSelectedIndex(entries.length - 1); + } + }, [entries, selectedIndex]); + + const listItems = useMemo( + (): ListItemData[] => entries.map(toListItem), + [entries], + ); + + /** + * Moves selection to the previous visible row, wrapping at the top. + */ + const selectPrevious = useCallback((): void => { + setSelectedIndex((previous) => + previous <= 0 ? Math.max(entries.length - 1, 0) : previous - 1, + ); + }, [entries.length]); + + /** + * Moves selection to the next visible row, wrapping at the bottom. + */ + const selectNext = useCallback((): void => { + setSelectedIndex((previous) => + entries.length === 0 + ? 0 + : previous >= entries.length - 1 + ? 0 + : previous + 1, + ); + }, [entries.length]); + + /** + * Applies the current row: navigate for parent/directory, select for files. + */ + const activateSelectedEntry = useCallback((): void => { + const entry = entries[selectedIndex]; + if (!entry) { + return; + } + + if (entry.kind === "parent" || entry.kind === "directory") { + setCurrentDirectory(entry.absolutePath); + onDirectoryChange?.(entry.absolutePath); + return; + } + + onSelectFile(entry.absolutePath); + }, [entries, onDirectoryChange, onSelectFile, selectedIndex]); + + /** + * Dialog overlays must pass `layerId` because ScrollableList uses raw ink + * `useInput`, which does not respect the input capture stack. + */ + useLayeredInput( + layerId ?? "file-picker-standalone", + (_input, key) => { + if (!layerId) { + return; + } + + if (key.upArrow) { + selectPrevious(); + return; + } + + if (key.downArrow) { + selectNext(); + return; + } + + if (key.return) { + activateSelectedEntry(); + } + }, + { isActive: Boolean(layerId) }, + ); + + const renderItem = useCallback( + ( + item: ListItemData, + isSelected: boolean, + isFocused: boolean, + ): React.ReactNode => { + const entry = item.value; + /** + * Inside dialogs, ScrollableList focus is disabled (input comes from layerId). + * Treat the selected row as highlighted so it matches other focused lists. + */ + const isHighlighted = layerId ? isSelected : isFocused; + const textColor = isHighlighted ? colors.focus : colors.text; + const indicator = isHighlighted ? "▸ " : " "; + + if (entry?.kind === "parent") { + return ( + + {indicator} + ⬆ .. + + ); + } + + if (entry?.kind === "directory") { + return ( + + {indicator} + 📁 {item.label}/ + + ); + } + + return ( + + {indicator} + {item.label} + + ); + }, + [layerId], + ); + + const listFocus = layerId ? false : focus; + + return ( + + + Directory: {formatDirectoryPath(currentDirectory)} + + + {loadError ? ( + + {loadError} + + ) : null} + + + activateSelectedEntry()} + focus={listFocus} + maxVisible={maxVisible} + emptyMessage="No matching files or folders" + renderItem={renderItem} + /> + + + ); +} diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index 3d25e38..5a5244d 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -8,10 +8,12 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; +import path from 'node:path'; import { ScrollableList, type ListItemData } from '../components/List.js'; +import { FilePicker } from '../components/FilePicker.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; -import { useBlockableInput } from '../hooks/useInputLayer.js'; +import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js'; import { colors, logoSmall } from '../theme.js'; // XO Imports @@ -25,6 +27,8 @@ import { getTemplateRoles, } from '../../utils/template-utils.js'; import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js'; +import { loadTemplateFromFile } from '../../utils/load-template-from-file.js'; +import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js'; /** * Template item with metadata. @@ -53,6 +57,55 @@ interface TemplateActionItem { source: 'starting' | 'next' | 'starting+next'; } +/** List item key for the synthetic import row. */ +const IMPORT_TEMPLATE_KEY = 'import-template'; + +/** Input layer id shared by the import dialog and its file picker. */ +const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog'; + +/** + * Import template dialog overlay. + * Captures keyboard input and wraps the generic {@link FilePicker}. + */ +function ImportTemplateDialogOverlay({ + onClose, + onSelectFile, +}: { + onClose: () => void; + onSelectFile: (filePath: string) => void; +}): React.ReactElement { + useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID); + + useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => { + if (key.escape) { + onClose(); + } + }); + + return ( + + + Select a JSON, JavaScript, or TypeScript template file from disk. + + + + + + + + + ↑↓ navigate • Enter open/select • Esc cancel + + + + ); +} + /** * Template List Screen Component. * Displays templates and their starting actions. @@ -68,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement { const [selectedActionIndex, setSelectedActionIndex] = useState(0); const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates'); const [isLoading, setIsLoading] = useState(true); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [templateToDelete, setTemplateToDelete] = useState(null); + const isCaptured = useIsInputCaptured(); /** * Loads templates from the engine. @@ -196,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement { loadTemplates(); }, [loadTemplates]); - // Get current template and its actions - const currentTemplate = templates[selectedTemplateIndex]; - const currentActions = currentTemplate?.availableActions ?? []; - /** * Build template list items for ScrollableList. */ const templateListItems = useMemo((): TemplateListItem[] => { - return templates.map((item, index) => { + const importTemplateItem: TemplateListItem = { + key: IMPORT_TEMPLATE_KEY, + label: 'Import Template', + description: 'Import a template from a file', + value: { + templateIdentifier: IMPORT_TEMPLATE_KEY, + template: {} as XOTemplate, + availableActions: [], + }, + hidden: false, + }; + + return [...templates.map((item, index) => { const formatted = formatTemplateListItem(item.template, index); return { key: item.templateIdentifier, @@ -213,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement { value: item, hidden: !formatted.isValid, }; - }); + }), importTemplateItem]; }, [templates]); + const selectedTemplateListItem = templateListItems[selectedTemplateIndex]; + const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY; + const currentTemplate = isImportRowSelected + ? undefined + : selectedTemplateListItem?.value; + const currentActions = currentTemplate?.availableActions ?? []; + /** * Build action list items for ScrollableList. */ @@ -246,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement { setSelectedActionIndex(0); // Reset action selection when template changes }, []); + /** + * Opens the import file picker. + */ + const openImportDialog = useCallback(() => { + setIsImportDialogOpen(true); + }, []); + + /** + * Opens the import file picker when the synthetic import row is activated. + */ + const handleTemplateActivate = useCallback((item: TemplateListItem) => { + if (item.key === IMPORT_TEMPLATE_KEY) { + openImportDialog(); + } + }, [openImportDialog]); + + /** + * Opens delete confirmation for the currently selected template. + */ + const openDeleteDialog = useCallback(() => { + if (!currentTemplate) { + return; + } + + setTemplateToDelete(currentTemplate); + setIsDeleteDialogOpen(true); + }, [currentTemplate]); + + /** + * Deletes the confirmed template from local storage. + */ + const handleConfirmDelete = useCallback(async () => { + if (!appService || !templateToDelete) { + return; + } + + const deletedName = + templateToDelete.template.name || templateToDelete.templateIdentifier; + + try { + setStatus('Deleting template...'); + await appService.engine.DANGEROUS_deleteImportedTemplate( + templateToDelete.templateIdentifier, + ); + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + await loadTemplates(); + setStatus(`Deleted ${deletedName}`); + } catch (error) { + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + showError( + `Failed to delete template: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [appService, loadTemplates, setStatus, showError, templateToDelete]); + + /** + * Loads the selected template file and imports it through the engine. + */ + const handleImportFile = useCallback(async (filePath: string) => { + if (!appService) { + showError('AppService not initialized'); + return; + } + + try { + setStatus('Importing template...'); + const content = await loadTemplateFromFile(filePath); + await appService.engine.importTemplate(content); + await loadTemplates(); + setIsImportDialogOpen(false); + setStatus(`Imported ${path.basename(filePath)}`); + } catch (error) { + showError( + `Failed to import template: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [appService, loadTemplates, setStatus, showError]); + /** * Handles action selection. * Navigates to the Action Wizard where the user will choose their role. @@ -264,12 +416,25 @@ export function TemplateListScreen(): React.ReactElement { }); }, [currentTemplate, navigate]); - // Handle keyboard navigation - useBlockableInput((_input, key) => { + // Handle keyboard navigation and template shortcuts + useBlockableInput((input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); return; } + + if (isLoading || focusedPanel !== 'templates') { + return; + } + + if (input === 'a' || input === 'A') { + openImportDialog(); + return; + } + + if ((input === 'd' || input === 'D') && currentTemplate) { + openDeleteDialog(); + } }); /** @@ -338,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement { items={templateListItems} selectedIndex={selectedTemplateIndex} onSelect={handleTemplateSelect} - focus={focusedPanel === 'templates'} + onActivate={handleTemplateActivate} + focus={focusedPanel === 'templates' && !isCaptured} emptyMessage="No templates imported" renderItem={renderTemplateItem} /> @@ -360,6 +526,12 @@ export function TemplateListScreen(): React.ReactElement { Loading... + ) : isImportRowSelected ? ( + + + Import a template to see available actions + + ) : !currentTemplate ? ( Select a template... @@ -370,7 +542,7 @@ export function TemplateListScreen(): React.ReactElement { selectedIndex={selectedActionIndex} onSelect={setSelectedActionIndex} onActivate={handleActionActivate} - focus={focusedPanel === 'actions'} + focus={focusedPanel === 'actions' && !isCaptured} emptyMessage="No actions available" renderItem={renderActionItem} /> @@ -392,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement { Description {/* Show template description when templates panel is focused */} - {focusedPanel === 'templates' && currentTemplate ? ( + {focusedPanel === 'templates' && isImportRowSelected ? ( + + Import Template + + Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched. + Press Enter or a to open the file picker. + + + ) : focusedPanel === 'templates' && currentTemplate ? ( {currentTemplate.template.name || 'Unnamed Template'} @@ -471,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement { {/* Help text */} - Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back + {focusedPanel === 'templates' && isImportRowSelected + ? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back' + : focusedPanel === 'templates' && currentTemplate + ? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back' + : 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'} + + {/* Import template dialog overlay */} + {isImportDialogOpen && ( + + setIsImportDialogOpen(false)} + onSelectFile={handleImportFile} + /> + + )} + + {/* Delete template confirmation dialog */} + {isDeleteDialogOpen && templateToDelete && ( + + { + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + }} + /> + + )} ); } diff --git a/src/tui/utils/format-dialog-message.ts b/src/tui/utils/format-dialog-message.ts new file mode 100644 index 0000000..61d4d99 --- /dev/null +++ b/src/tui/utils/format-dialog-message.ts @@ -0,0 +1,121 @@ +/** + * Formats multi-line dialog messages for readable terminal display. + * + * Ink's `wrap="wrap"` breaks long lines mid-word, which looks broken for + * dot-separated template validation paths. We pre-split on newlines and break + * long lines at `.` segment boundaries instead. + */ + +/** + * Hard-wraps text when a single segment still exceeds the maximum width. + */ +function hardWrapLine(line: string, maxWidth: number): string[] { + if (line.length <= maxWidth) { + return [line]; + } + + const wrapped: string[] = []; + let remaining = line; + + while (remaining.length > maxWidth) { + wrapped.push(remaining.slice(0, maxWidth)); + remaining = ` ${remaining.slice(maxWidth)}`; + } + + if (remaining.length > 0) { + wrapped.push(remaining); + } + + return wrapped; +} + +/** + * Breaks a long line at dot-separated segments, indenting continuations. + */ +function breakLongLineAtDots(line: string, maxWidth: number): string[] { + const segments: string[] = []; + let segmentStart = 0; + + for (let index = 0; index < line.length; index += 1) { + if (line[index] === "." && index > 0) { + segments.push(line.slice(segmentStart, index + 1)); + segmentStart = index + 1; + } + } + + if (segmentStart < line.length) { + segments.push(line.slice(segmentStart)); + } + + if (segments.length === 0) { + return hardWrapLine(line, maxWidth); + } + + const lines: string[] = []; + let current = ""; + + for (const segment of segments) { + const candidate = current + segment; + + if (candidate.length > maxWidth && current.length > 0) { + lines.push(current); + current = ` ${segment}`; + continue; + } + + if (candidate.length > maxWidth) { + lines.push(...hardWrapLine(segment, maxWidth)); + current = ""; + continue; + } + + current = candidate; + } + + if (current.length > 0) { + lines.push(current); + } + + return lines; +} + +/** + * Splits a dialog message into display lines that fit the available width. + */ +export function formatDialogMessageLines( + message: string, + contentWidth: number, +): string[] { + const output: string[] = []; + + for (const rawLine of message.split("\n")) { + const line = rawLine.trimEnd(); + if (line.length === 0) { + continue; + } + + if (line.length <= contentWidth) { + output.push(line); + continue; + } + + output.push(...breakLongLineAtDots(line, contentWidth)); + } + + return output; +} + +/** + * Computes dialog width from the terminal size. + */ +export function getMessageDialogWidth(terminalColumns: number): number { + return Math.min(Math.max(terminalColumns - 4, 60), 100); +} + +/** Inner text width after dialog border and horizontal padding. */ +export function getMessageContentWidth(dialogWidth: number): number { + return Math.max(dialogWidth - 6, 40); +} + +/** Maximum number of body lines shown before truncating with a summary. */ +export const MAX_MESSAGE_DIALOG_LINES = 24; diff --git a/src/tui/utils/list-directory-entries.ts b/src/tui/utils/list-directory-entries.ts new file mode 100644 index 0000000..fa1bb1c --- /dev/null +++ b/src/tui/utils/list-directory-entries.ts @@ -0,0 +1,170 @@ +/** + * Directory listing helpers for terminal file pickers. + * + * Uses synchronous filesystem APIs to match other TUI screens (e.g. SeedInput). + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Kind of entry shown in a file picker list. + */ +export type DirectoryEntryKind = "parent" | "directory" | "file"; + +/** + * A single row in a directory listing. + */ +export interface DirectoryEntry { + /** Display name (e.g. ".." or "foo.json"). */ + name: string; + /** Absolute path on disk. */ + absolutePath: string; + /** Whether this row navigates up, into a folder, or selects a file. */ + kind: DirectoryEntryKind; +} + +/** + * Options for {@link listDirectoryEntries}. + */ +export interface ListDirectoryEntriesOptions { + /** + * Allowed file extensions without a leading dot (e.g. `['json']`). + * When omitted or empty, all non-hidden files are included. + * Directories are always included regardless of this filter. + */ + extensions?: string[]; +} + +/** + * Result of listing a directory for the file picker. + */ +export interface ListDirectoryEntriesResult { + entries: DirectoryEntry[]; + /** Set when the directory could not be read. */ + error?: string; +} + +/** + * Returns true when the file extension matches one of the allowed extensions. + * Comparison is case-insensitive; extensions may be passed with or without a dot. + */ +function matchesExtension( + filename: string, + extensions: string[] | undefined, +): boolean { + if (extensions === undefined || extensions.length === 0) { + return true; + } + + const fileExtension = path.extname(filename).slice(1).toLowerCase(); + if (fileExtension.length === 0) { + return false; + } + + return extensions.some((extension) => { + const normalized = extension.startsWith(".") + ? extension.slice(1) + : extension; + return normalized.toLowerCase() === fileExtension; + }); +} + +/** + * Lists files and folders in `directory` for display in a terminal file picker. + * + * - Prepends `..` when not at the filesystem root. + * - Always shows subdirectories (except `.` and `..` from readdir). + * - Filters files by optional `extensions`. + * - Sort order: parent link first, then directories A→Z, then files A→Z. + * - Returns an empty list and `error` instead of throwing on permission or missing paths. + */ +export function listDirectoryEntries( + directory: string, + options: ListDirectoryEntriesOptions = {}, +): ListDirectoryEntriesResult { + const resolvedDirectory = path.resolve(directory); + const { extensions } = options; + + try { + if (!fs.existsSync(resolvedDirectory)) { + return { + entries: [], + error: `Directory does not exist: ${resolvedDirectory}`, + }; + } + + const directoryStat = fs.statSync(resolvedDirectory); + if (!directoryStat.isDirectory()) { + return { + entries: [], + error: `Not a directory: ${resolvedDirectory}`, + }; + } + + const entries: DirectoryEntry[] = []; + const parentDirectory = path.dirname(resolvedDirectory); + + if (parentDirectory !== resolvedDirectory) { + entries.push({ + name: "..", + absolutePath: parentDirectory, + kind: "parent", + }); + } + + const childNames = fs.readdirSync(resolvedDirectory); + const directories: DirectoryEntry[] = []; + const files: DirectoryEntry[] = []; + + for (const name of childNames) { + if (name === "." || name === "..") { + continue; + } + + const absolutePath = path.join(resolvedDirectory, name); + + let childStat: fs.Stats; + try { + childStat = fs.statSync(absolutePath); + } catch { + // Skip broken symlinks or entries we cannot stat. + continue; + } + + if (childStat.isDirectory()) { + directories.push({ + name, + absolutePath, + kind: "directory", + }); + continue; + } + + if (childStat.isFile() && matchesExtension(name, extensions)) { + files.push({ + name, + absolutePath, + kind: "file", + }); + } + } + + const sortByName = (a: DirectoryEntry, b: DirectoryEntry): number => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + + directories.sort(sortByName); + files.sort(sortByName); + + return { + entries: [...entries, ...directories, ...files], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { + entries: [], + error: `Unable to read directory: ${message}`, + }; + } +} diff --git a/src/utils/load-template-from-file.ts b/src/utils/load-template-from-file.ts new file mode 100644 index 0000000..aa76a58 --- /dev/null +++ b/src/utils/load-template-from-file.ts @@ -0,0 +1,194 @@ +/** + * Loads template file contents for {@link Engine.importTemplate}. + * + * - `.json` files are read directly. + * - `.ts`, `.js`, `.mts`, `.cts`, `.mjs`, `.cjs` files are evaluated in a + * short-lived child process and serialized to Extended JSON on stdout. + * TypeScript templates (and the loader in dev) run via tsx; plain JS uses node. + */ + +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Extensions loaded via subprocess module evaluation. */ +const MODULE_TEMPLATE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", +]); + +/** Maximum time allowed for a template module child process. */ +const MODULE_LOAD_TIMEOUT_MS = 30_000; + +/** Maximum stdout size from the loader child (50 MiB). */ +const MODULE_LOAD_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +/** + * Thrown when a template file cannot be read or loaded. + */ +export class TemplateLoadError extends Error { + constructor(message: string) { + super(message); + this.name = "TemplateLoadError"; + } +} + +/** + * Resolves the tsx CLI binary shipped with this package. + */ +function resolveTsxCliPath(): string { + const require = createRequire(import.meta.url); + const tsxPackageJsonPath = require.resolve("tsx/package.json"); + return path.join(path.dirname(tsxPackageJsonPath), "dist/cli.mjs"); +} + +/** + * Resolves the loader script path for dev (.ts) and production (.js) layouts. + */ +function resolveTemplateModuleLoaderPath(): string { + const directory = path.dirname(fileURLToPath(import.meta.url)); + const compiledLoaderPath = path.join(directory, "template-module-loader.js"); + if (fs.existsSync(compiledLoaderPath)) { + return compiledLoaderPath; + } + + const sourceLoaderPath = path.join(directory, "template-module-loader.ts"); + if (fs.existsSync(sourceLoaderPath)) { + return sourceLoaderPath; + } + + throw new TemplateLoadError( + "Template module loader script was not found in the xo-cli package.", + ); +} + +/** TypeScript extensions that require tsx to evaluate the template module. */ +const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", +]); + +/** + * Loads a TS/JS template module in an isolated child process. + * Returns Extended JSON suitable for {@link parseTemplate}. + */ +async function loadTemplateModuleViaChildProcess( + absolutePath: string, +): Promise { + const loaderPath = resolveTemplateModuleLoaderPath(); + const extension = path.extname(absolutePath).toLowerCase(); + const loaderIsTypeScript = loaderPath.endsWith(".ts"); + const useTsx = + TYPESCRIPT_TEMPLATE_EXTENSIONS.has(extension) || loaderIsTypeScript; + const executable = useTsx ? resolveTsxCliPath() : process.execPath; + const args = [loaderPath, absolutePath]; + + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { + cwd: path.dirname(absolutePath), + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + reject( + new TemplateLoadError( + `Template module load timed out after ${MODULE_LOAD_TIMEOUT_MS / 1000}s`, + ), + ); + }, MODULE_LOAD_TIMEOUT_MS); + + child.stdout.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + stdoutBytes += Buffer.byteLength(text, "utf8"); + if (stdoutBytes > MODULE_LOAD_MAX_BUFFER_BYTES) { + child.kill("SIGTERM"); + reject( + new TemplateLoadError( + "Template module output exceeded the maximum allowed size.", + ), + ); + return; + } + stdout += text; + }); + + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + reject( + new TemplateLoadError( + `Failed to start template module loader: ${error.message}`, + ), + ); + }); + + child.on("close", (code) => { + clearTimeout(timeout); + + if (code !== 0) { + reject( + new TemplateLoadError( + stderr.trim() || + `Template module loader exited with code ${code ?? "unknown"}`, + ), + ); + return; + } + + if (stdout.trim().length === 0) { + reject(new TemplateLoadError("Template module loader returned no output.")); + return; + } + + resolve(stdout); + }); + }); +} + +/** + * Loads template contents from disk. + * + * @param filePath - Absolute or relative path to a JSON or module template file. + * @returns Extended JSON string for {@link Engine.importTemplate}. + */ +export async function loadTemplateFromFile(filePath: string): Promise { + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`); + } + + const extension = path.extname(absolutePath).toLowerCase(); + + if (extension === ".json") { + return fs.promises.readFile(absolutePath, "utf8"); + } + + if (MODULE_TEMPLATE_EXTENSIONS.has(extension)) { + return loadTemplateModuleViaChildProcess(absolutePath); + } + + throw new TemplateLoadError( + `Unsupported template file extension "${extension}". ` + + "Use .json or a JavaScript/TypeScript module that exports an XOTemplate.", + ); +} diff --git a/src/utils/pick-template-export.ts b/src/utils/pick-template-export.ts new file mode 100644 index 0000000..30bd144 --- /dev/null +++ b/src/utils/pick-template-export.ts @@ -0,0 +1,64 @@ +/** + * Helpers for finding an {@link XOTemplate} export in a loaded ES module. + */ + +/** + * Returns true when `value` looks like an XOTemplate object (pre-schema check). + * Used only to pick the correct export before {@link parseTemplate} validates fully. + */ +export function isTemplateLike(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.$schema === "string" && + typeof candidate.name === "string" && + typeof candidate.roles === "object" && + candidate.roles !== null + ); +} + +/** + * Picks the single XOTemplate export from a dynamically loaded module. + * + * Resolution order: + * 1. `default` export, when template-like + * 2. Exactly one named template-like export + * + * @throws When no template export exists or multiple template exports are found. + */ +export function pickTemplateExport( + moduleExports: Record, +): Record { + const defaultExport = moduleExports.default; + if (isTemplateLike(defaultExport)) { + return defaultExport; + } + + const namedTemplateExports = Object.entries(moduleExports).filter( + ([exportName, exportValue]) => + exportName !== "default" && isTemplateLike(exportValue), + ); + + if (namedTemplateExports.length === 1) { + const [, exportValue] = namedTemplateExports[0]!; + if (!isTemplateLike(exportValue)) { + throw new Error("No XOTemplate export found."); + } + return exportValue; + } + + if (namedTemplateExports.length > 1) { + const exportNames = namedTemplateExports.map(([name]) => name).join(", "); + throw new Error( + `Multiple template exports found (${exportNames}). ` + + "Use a single named export or a default export.", + ); + } + + throw new Error( + "No XOTemplate export found. Export a template object as `default` or a named export.", + ); +} diff --git a/src/utils/template-module-loader.ts b/src/utils/template-module-loader.ts new file mode 100644 index 0000000..f5ba077 --- /dev/null +++ b/src/utils/template-module-loader.ts @@ -0,0 +1,34 @@ +/** + * Child-process entry point for loading a TS/JS template module. + * + * Usage (via tsx): `tsx template-module-loader.js ` + * + * Writes serialized Extended JSON to stdout. Errors go to stderr with exit code 1. + * Running in a subprocess isolates module evaluation from the wallet process. + */ + +import { pathToFileURL } from "node:url"; + +import { serializeTemplate } from "@xo-cash/utils"; +import type { XOTemplate } from "@xo-cash/types"; + +import { pickTemplateExport } from "./pick-template-export.js"; + +const templateFilePath = process.argv[2]; + +if (templateFilePath === undefined || templateFilePath.length === 0) { + console.error("Usage: template-module-loader "); + process.exit(1); +} + +try { + const moduleUrl = pathToFileURL(templateFilePath).href; + const loadedModule = (await import(moduleUrl)) as Record; + const template = pickTemplateExport(loadedModule); + process.stdout.write(serializeTemplate(template as XOTemplate)); +} catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error(`Failed to load template module: ${message}`); + process.exit(1); +} diff --git a/tests/cli/mnemonic.test.ts b/tests/cli/mnemonic.test.ts index d0abfc0..baccac9 100644 --- a/tests/cli/mnemonic.test.ts +++ b/tests/cli/mnemonic.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -110,7 +111,13 @@ describe("mnemonic utilities", () => { "/nonexistent", "mnemonic-relative", ); - expect(resolved).toBe(path.join(tempDir, "mnemonic-relative")); + + // Due to some weird MacOS behavior we need to use realpathSync to get the correct path + // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` + const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative")); + + // Compare to the expected path + expect(resolved).toBe(expectedPath); } finally { process.chdir(originalCwd); } diff --git a/tests/cli/mocks/engine.ts b/tests/cli/mocks/engine.ts index 18ee48f..a8486a9 100644 --- a/tests/cli/mocks/engine.ts +++ b/tests/cli/mocks/engine.ts @@ -1,3 +1,6 @@ +// Node js tool for temp dir +import { tmpdir } from "node:os"; + import { BlockchainMonitor, Engine } from "@xo-cash/engine"; import { @@ -16,6 +19,7 @@ import { InMemoryStorage } from "../../../src/services/storage"; import { MockElectrumService } from "./electrum-service"; import { MockRatesService } from "./rates-service"; import { RatesService } from "../../../src/services/rates"; +import { SettingsService } from "../../../src/services/settings"; export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer"; @@ -67,8 +71,6 @@ export const addFakeResource = async ( status: UnspentOutputStatus.CONFIRMED, selectable: true, privacy: false, - templateIdentifier: options.templateIdentifier ?? "test-template", - outputIdentifier: options.outputIdentifier ?? "receiveOutput", outpointIndex: options.outpointIndex ?? 0, outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(), minedAtHeight: options.minedAtHeight ?? 800000, @@ -143,10 +145,7 @@ export const createMockEngine = async (seed: string) => { // Create the in-memory blockchain provider. const blockchainProvider = new InMemoryBlockchainProvider(); - await blockchainProvider.initialize({ - applicationIdentifier: "xo-cli-tests", - electrumOptions: {}, - }); + await blockchainProvider.initialize(); // Create the blockchain monitor instance. const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider); @@ -160,10 +159,13 @@ export const createMockEngine = async (seed: string) => { }; export const createMockAppService = async (engine: Engine) => { + const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`); + settings.setCurrency("USD"); + const storage = await InMemoryStorage.create(); const mockRates = new MockRatesService(); - const rates = new RatesService(mockRates); + const rates = new RatesService(mockRates, settings); const mockElectrum = new MockElectrumService(); @@ -176,5 +178,5 @@ export const createMockAppService = async (engine: Engine) => { invitationStoragePath: "test-invitations.db", }; - return new AppService(engine, storage, config, mockElectrum, rates); + return new AppService(engine, storage, config, mockElectrum, rates, settings); }; diff --git a/tests/cli/paths.test.ts b/tests/cli/paths.test.ts index 5fb11b8..b63351c 100644 --- a/tests/cli/paths.test.ts +++ b/tests/cli/paths.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -93,7 +93,13 @@ describe("paths utilities", () => { try { writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test"); const resolved = resolveMnemonicFilePath("mnemonic-cwd-test"); - expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test")); + + // Due to some weird MacOS behavior we need to use realpathSync to get the correct path + // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` + const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test")); + + // Compare to the expected path + expect(resolved).toBe(expectedPath); } finally { process.chdir(originalCwd); } diff --git a/tests/tui/format-dialog-message.test.ts b/tests/tui/format-dialog-message.test.ts new file mode 100644 index 0000000..ecb35a9 --- /dev/null +++ b/tests/tui/format-dialog-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { + formatDialogMessageLines, + getMessageContentWidth, + getMessageDialogWidth, +} from "../../src/tui/utils/format-dialog-message.js"; + +describe("formatDialogMessageLines", () => { + test("drops empty lines from leading newlines", () => { + const lines = formatDialogMessageLines("\n- first\n- second", 80); + + expect(lines).toEqual(["- first", "- second"]); + }); + + test("keeps short lines unchanged", () => { + const lines = formatDialogMessageLines("- actions.receive: Invalid", 80); + + expect(lines).toEqual(["- actions.receive: Invalid"]); + }); + + test("breaks long dot-separated paths at segment boundaries", () => { + const line = + "- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\""; + const lines = formatDialogMessageLines(line, 56); + + expect(lines.length).toBeGreaterThan(1); + expect(lines.join("\n")).toContain("actions.requestFungibleTokens."); + expect(lines.every((entry) => entry.length <= 58)).toBe(true); + expect(lines[1]?.startsWith(" ")).toBe(true); + }); +}); + +describe("dialog width helpers", () => { + test("getMessageDialogWidth respects terminal bounds", () => { + expect(getMessageDialogWidth(120)).toBe(100); + expect(getMessageDialogWidth(80)).toBe(76); + expect(getMessageDialogWidth(40)).toBe(60); + }); + + test("getMessageContentWidth subtracts border and padding", () => { + expect(getMessageContentWidth(76)).toBe(70); + }); +}); diff --git a/tests/tui/list-directory-entries.test.ts b/tests/tui/list-directory-entries.test.ts new file mode 100644 index 0000000..a565ef5 --- /dev/null +++ b/tests/tui/list-directory-entries.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { listDirectoryEntries } from "../../src/tui/utils/list-directory-entries.js"; + +describe("listDirectoryEntries", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "xo-file-picker-")); + }); + + afterEach(() => { + if (existsSync(tempRoot)) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("includes parent entry and sorts directories before files", () => { + mkdirSync(path.join(tempRoot, "beta-dir")); + mkdirSync(path.join(tempRoot, "alpha-dir")); + writeFileSync(path.join(tempRoot, "zebra.json"), "{}"); + writeFileSync(path.join(tempRoot, "apple.txt"), "x"); + + const childDir = path.join(tempRoot, "child"); + mkdirSync(childDir); + writeFileSync(path.join(childDir, "nested.json"), "{}"); + + const rootResult = listDirectoryEntries(tempRoot); + expect(rootResult.error).toBeUndefined(); + expect(rootResult.entries.map((entry) => entry.name)).toEqual([ + "..", + "alpha-dir", + "beta-dir", + "child", + "apple.txt", + "zebra.json", + ]); + + const childResult = listDirectoryEntries(childDir); + expect(childResult.entries[0]).toMatchObject({ + name: "..", + kind: "parent", + absolutePath: tempRoot, + }); + expect(childResult.entries.slice(1).map((entry) => entry.name)).toEqual([ + "nested.json", + ]); + }); + + test("filters files by extension when extensions are provided", () => { + writeFileSync(path.join(tempRoot, "template.json"), "{}"); + writeFileSync(path.join(tempRoot, "readme.md"), "# hi"); + writeFileSync(path.join(tempRoot, "UPPER.JSON"), "{}"); + + const result = listDirectoryEntries(tempRoot, { extensions: ["json"] }); + + expect(result.error).toBeUndefined(); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "..", + "template.json", + "UPPER.JSON", + ]); + }); + + test("shows all files when extensions are omitted", () => { + writeFileSync(path.join(tempRoot, "a.json"), "{}"); + writeFileSync(path.join(tempRoot, "b.txt"), "x"); + + const result = listDirectoryEntries(tempRoot); + + expect(result.entries.map((entry) => entry.name)).toEqual([ + "..", + "a.json", + "b.txt", + ]); + }); + + test("omits parent entry at filesystem root", () => { + const rootResult = listDirectoryEntries(path.parse(tempRoot).root); + + expect(rootResult.error).toBeUndefined(); + expect(rootResult.entries.some((entry) => entry.kind === "parent")).toBe( + false, + ); + }); + + test("returns error for missing directory without throwing", () => { + const missingPath = path.join(tempRoot, "does-not-exist"); + + const result = listDirectoryEntries(missingPath); + + expect(result.entries).toEqual([]); + expect(result.error).toContain("Directory does not exist"); + }); + + test("returns error when path is a file", () => { + const filePath = path.join(tempRoot, "file.txt"); + writeFileSync(filePath, "hello"); + + const result = listDirectoryEntries(filePath); + + expect(result.entries).toEqual([]); + expect(result.error).toContain("Not a directory"); + }); +}); diff --git a/tests/utils/load-template-from-file.test.ts b/tests/utils/load-template-from-file.test.ts new file mode 100644 index 0000000..69756ae --- /dev/null +++ b/tests/utils/load-template-from-file.test.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { parseTemplate } from "@xo-cash/utils"; + +import { + loadTemplateFromFile, + TemplateLoadError, +} from "../../src/utils/load-template-from-file.js"; +import { p2pkhTemplate } from "../cli/mocks/template-p2pkh.js"; + +describe("loadTemplateFromFile", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "xo-load-template-")); + }); + + afterEach(() => { + if (existsSync(tempRoot)) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("loads JSON templates directly", async () => { + const jsonPath = path.join(tempRoot, "template.json"); + writeFileSync(jsonPath, JSON.stringify(p2pkhTemplate)); + + const contents = await loadTemplateFromFile(jsonPath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe(p2pkhTemplate.name); + }); + + test("loads TypeScript templates via child process", async () => { + const tsTemplatePath = path.resolve( + process.cwd(), + "../templates/source/p2pkh.ts", + ); + expect(existsSync(tsTemplatePath)).toBe(true); + + const contents = await loadTemplateFromFile(tsTemplatePath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe("Wallet (P2PKH)"); + }); + + test("loads JavaScript templates via child process", async () => { + const jsPath = path.join(tempRoot, "template.mjs"); + writeFileSync( + jsPath, + `export default ${JSON.stringify(p2pkhTemplate)};\n`, + "utf8", + ); + + const contents = await loadTemplateFromFile(jsPath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe(p2pkhTemplate.name); + }); + + test("throws TemplateLoadError for missing files", async () => { + await expect( + loadTemplateFromFile(path.join(tempRoot, "missing.json")), + ).rejects.toBeInstanceOf(TemplateLoadError); + }); + + test("throws TemplateLoadError for unsupported extensions", async () => { + const txtPath = path.join(tempRoot, "template.txt"); + writeFileSync(txtPath, "hello"); + + await expect(loadTemplateFromFile(txtPath)).rejects.toThrow( + /Unsupported template file extension/, + ); + }); +}); diff --git a/tests/utils/pick-template-export.test.ts b/tests/utils/pick-template-export.test.ts new file mode 100644 index 0000000..0e190b1 --- /dev/null +++ b/tests/utils/pick-template-export.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { + isTemplateLike, + pickTemplateExport, +} from "../../src/utils/pick-template-export.js"; + +const sampleTemplate = { + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", + name: "Sample", + roles: { owner: { name: "Owner" } }, +}; + +describe("pickTemplateExport", () => { + test("isTemplateLike accepts objects with schema, name, and roles", () => { + expect(isTemplateLike(sampleTemplate)).toBe(true); + expect(isTemplateLike(null)).toBe(false); + expect(isTemplateLike({ name: "Missing schema" })).toBe(false); + }); + + test("prefers default export when template-like", () => { + const picked = pickTemplateExport({ + default: sampleTemplate, + otherTemplate: { + ...sampleTemplate, + name: "Other", + }, + }); + + expect(picked).toBe(sampleTemplate); + }); + + test("uses a single named export when no default export exists", () => { + const picked = pickTemplateExport({ + p2pkhTemplate: sampleTemplate, + }); + + expect(picked).toBe(sampleTemplate); + }); + + test("throws when multiple template exports exist", () => { + expect(() => + pickTemplateExport({ + firstTemplate: sampleTemplate, + secondTemplate: { ...sampleTemplate, name: "Second" }, + }), + ).toThrow(/Multiple template exports found/); + }); + + test("throws when no template export exists", () => { + expect(() => + pickTemplateExport({ + notATemplate: { foo: "bar" }, + }), + ).toThrow(/No XOTemplate export found/); + }); +});