Add import template into tui. Fix tests that fail on macos. Fix some updates.
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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-<name> <value>`
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- import <template-file> ${dim("Import a template from a file")}
|
||||
- import <template-file> ${dim("Import a template from a JSON, JS, or TS file")}
|
||||
- list ${dim("List all templates")}
|
||||
- list <category> <identifier> ${dim("List all options of the field type in a template")}
|
||||
- inspect <category> <identifier> <field> ${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
|
||||
|
||||
@@ -625,34 +625,26 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
);
|
||||
}
|
||||
|
||||
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<string, XOInvitationVariableValue>,
|
||||
);
|
||||
|
||||
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<InvitationEventMap> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
266
src/templates/wrap-template.ts
Normal file
266
src/templates/wrap-template.ts
Normal file
@@ -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 $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> 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 $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> 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 $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.',
|
||||
icon: 'receive',
|
||||
|
||||
valueSatoshis: '$(<amountToWrap>)',
|
||||
token: {
|
||||
category: '$(<wbchTokenCategory>)',
|
||||
amount: '$(<amountToWrap>)',
|
||||
nft: null,
|
||||
},
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
|
||||
unwrappedSatoshisOutput: {
|
||||
name: 'Unwrapped BCH',
|
||||
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.',
|
||||
icon: 'receive',
|
||||
|
||||
valueSatoshis: '$(<amountToUnwrap>)',
|
||||
token: null,
|
||||
|
||||
roles: {
|
||||
user: {
|
||||
balance: {
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonfungibleTokens: true,
|
||||
},
|
||||
selectable: true,
|
||||
},
|
||||
},
|
||||
|
||||
lockingScript: '$(<recipientLockingScript>)',
|
||||
},
|
||||
},
|
||||
|
||||
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' },
|
||||
],
|
||||
};
|
||||
@@ -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 (
|
||||
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
|
||||
<Text wrap="wrap">{message}</Text>
|
||||
<DialogWrapper
|
||||
title={`${icon} ${title}`}
|
||||
borderColor={borderColor}
|
||||
width={dialogWidth}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{messageLines.map((line, index) => (
|
||||
<Text key={`${index}-${line.slice(0, 24)}`}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||
</Box>
|
||||
|
||||
273
src/tui/components/FilePicker.tsx
Normal file
273
src/tui/components/FilePicker.tsx
Normal file
@@ -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<DirectoryEntry> {
|
||||
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<string | undefined>();
|
||||
|
||||
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<DirectoryEntry>[] => 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<DirectoryEntry>,
|
||||
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 (
|
||||
<Text color={textColor} bold={isSelected}>
|
||||
{indicator}
|
||||
⬆ ..
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry?.kind === "directory") {
|
||||
return (
|
||||
<Text color={textColor} bold={isSelected}>
|
||||
{indicator}
|
||||
📁 {item.label}/
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={textColor} bold={isSelected}>
|
||||
{indicator}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
[layerId],
|
||||
);
|
||||
|
||||
const listFocus = layerId ? false : focus;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.textMuted}>
|
||||
Directory: {formatDirectoryPath(currentDirectory)}
|
||||
</Text>
|
||||
|
||||
{loadError ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.error}>{loadError}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<ScrollableList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={() => activateSelectedEntry()}
|
||||
focus={listFocus}
|
||||
maxVisible={maxVisible}
|
||||
emptyMessage="No matching files or folders"
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DialogWrapper title="Import Template" borderColor={colors.primary} width={72}>
|
||||
<Text color={colors.textMuted}>
|
||||
Select a JSON, JavaScript, or TypeScript template file from disk.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<FilePicker
|
||||
layerId={IMPORT_TEMPLATE_DIALOG_LAYER_ID}
|
||||
extensions={['json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts']}
|
||||
onSelectFile={onSelectFile}
|
||||
maxVisible={8}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
↑↓ navigate • Enter open/select • Esc cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TemplateItem | null>(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 {
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : isImportRowSelected ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Import a template to see available actions
|
||||
</Text>
|
||||
</Box>
|
||||
) : !currentTemplate ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Select a template...</Text>
|
||||
@@ -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 {
|
||||
<Text color={colors.primary} bold> Description </Text>
|
||||
|
||||
{/* Show template description when templates panel is focused */}
|
||||
{focusedPanel === 'templates' && currentTemplate ? (
|
||||
{focusedPanel === 'templates' && isImportRowSelected ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>Import Template</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
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.
|
||||
</Text>
|
||||
</Box>
|
||||
) : focusedPanel === 'templates' && currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>
|
||||
{currentTemplate.template.name || 'Unnamed Template'}
|
||||
@@ -471,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
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'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import template dialog overlay */}
|
||||
{isImportDialogOpen && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<ImportTemplateDialogOverlay
|
||||
onClose={() => setIsImportDialogOpen(false)}
|
||||
onSelectFile={handleImportFile}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete template confirmation dialog */}
|
||||
{isDeleteDialogOpen && templateToDelete && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<ConfirmDialog
|
||||
title="Delete Template"
|
||||
message={
|
||||
`Delete "${templateToDelete.template.name || templateToDelete.templateIdentifier}"?\n\n` +
|
||||
'This removes the template from local storage. Invitations that use it may become unusable.'
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
121
src/tui/utils/format-dialog-message.ts
Normal file
121
src/tui/utils/format-dialog-message.ts
Normal file
@@ -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;
|
||||
170
src/tui/utils/list-directory-entries.ts
Normal file
170
src/tui/utils/list-directory-entries.ts
Normal file
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
194
src/utils/load-template-from-file.ts
Normal file
194
src/utils/load-template-from-file.ts
Normal file
@@ -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<string> {
|
||||
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<string>((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<string> {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
64
src/utils/pick-template-export.ts
Normal file
64
src/utils/pick-template-export.ts
Normal file
@@ -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<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
34
src/utils/template-module-loader.ts
Normal file
34
src/utils/template-module-loader.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Child-process entry point for loading a TS/JS template module.
|
||||
*
|
||||
* Usage (via tsx): `tsx template-module-loader.js <absolute-template-path>`
|
||||
*
|
||||
* 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 <absolute-template-path>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleUrl = pathToFileURL(templateFilePath).href;
|
||||
const loadedModule = (await import(moduleUrl)) as Record<string, unknown>;
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
44
tests/tui/format-dialog-message.test.ts
Normal file
44
tests/tui/format-dialog-message.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
114
tests/tui/list-directory-entries.test.ts
Normal file
114
tests/tui/list-directory-entries.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
78
tests/utils/load-template-from-file.test.ts
Normal file
78
tests/utils/load-template-from-file.test.ts
Normal file
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
57
tests/utils/pick-template-export.test.ts
Normal file
57
tests/utils/pick-template-export.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user