Compare commits
9 Commits
add-seed-g
...
a7f0ed69a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
a7f0ed69a2
|
|||
|
2f8dad7d8d
|
|||
|
85746c3306
|
|||
|
def261b568
|
|||
|
3d6518e465
|
|||
| bcc3277cb9 | |||
| 12b7bde74f | |||
| 42d23fa35e | |||
| b6ee25d1dd |
48
package-lock.json
generated
48
package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
"@xo-cash/types": "^0.0.1",
|
"@xo-cash/types": "^0.0.1",
|
||||||
|
"@xo-cash/utils": "file:../utils",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
@@ -47,16 +48,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
"@electrum-cash/application": "^0.2.3-development.13424909069",
|
"@electrum-cash/application": "^0.2.3-development.13447192992",
|
||||||
"@electrum-cash/network": "^4.2.2",
|
"@electrum-cash/network": "^4.2.2",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@electrum-cash/servers": "^3.1.0",
|
"@electrum-cash/servers": "^3.1.0",
|
||||||
"@xo-cash/crypto": "file:../crypto",
|
"@xo-cash/crypto": "0.0.1",
|
||||||
"@xo-cash/primitives": "0.0.1",
|
"@xo-cash/primitives": "file:../primitives",
|
||||||
"@xo-cash/state": "0.0.2",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "0.0.1",
|
"@xo-cash/templates": "0.0.1",
|
||||||
"@xo-cash/types": "0.0.1",
|
"@xo-cash/types": "^0.0.1-development.14519184304",
|
||||||
"@xo-cash/utils": "0.0.1",
|
"@xo-cash/utils": "^0.0.1-development.14519184505",
|
||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -140,6 +141,37 @@
|
|||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../utils": {
|
||||||
|
"name": "@xo-cash/utils",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
|
"@xo-cash/types": "0.0.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
|
"@stylistic/eslint-plugin": "^5.7.0",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||||
|
"@xo-cash/eslint-config": "1.0.1",
|
||||||
|
"@xo-cash/templates": "0.0.1",
|
||||||
|
"cspell": "^9.6.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"tsdown": "^0.20.0-beta.4",
|
||||||
|
"typedoc": "^0.28.16",
|
||||||
|
"typedoc-plugin-coverage": "^4.0.2",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"typescript-eslint": "^8.53.1",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alcalzone/ansi-tokenize": {
|
"node_modules/@alcalzone/ansi-tokenize": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
||||||
@@ -977,6 +1009,10 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xo-cash/utils": {
|
||||||
|
"resolved": "../utils",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
|
||||||
|
|||||||
@@ -41,12 +41,14 @@
|
|||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
"@xo-cash/types": "^0.0.1",
|
"@xo-cash/types": "^0.0.1",
|
||||||
|
"@xo-cash/utils": "file:../utils",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -55,7 +57,6 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
23
readme.md
23
readme.md
@@ -9,7 +9,7 @@ mkdir xo-terminal && cd xo-terminal
|
|||||||
|
|
||||||
# ----- Start Engine Setup -----
|
# ----- Start Engine Setup -----
|
||||||
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
||||||
git clone git@gitlab.com:Harvmaster/engine.git
|
git clone https://gitlab.com/Harvmaster/engine.git
|
||||||
|
|
||||||
# Move into teh engine directory
|
# Move into teh engine directory
|
||||||
cd engine
|
cd engine
|
||||||
@@ -29,7 +29,7 @@ cd ..
|
|||||||
|
|
||||||
# ----- Start State Setup -----
|
# ----- Start State Setup -----
|
||||||
# Clone the State Repo
|
# Clone the State Repo
|
||||||
git clone git@gitlab.com:Harvmaster/state.git
|
git clone https://gitlab.com/Harvmaster/state.git
|
||||||
|
|
||||||
# Move into the state directory
|
# Move into the state directory
|
||||||
cd state
|
cd state
|
||||||
@@ -46,9 +46,26 @@ npm run build
|
|||||||
# Move back to the top level directory
|
# Move back to the top level directory
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Template Setup ----
|
||||||
|
# Clone the Template repo
|
||||||
|
git clone https://gitlab.com/Harvmaster/templates.git
|
||||||
|
|
||||||
|
# Move into themplates directory
|
||||||
|
cd templates
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
#build the templates
|
||||||
|
npm run build
|
||||||
|
# ----- End Templates Setup ----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
# ----- Start CLI Setup -----
|
# ----- Start CLI Setup -----
|
||||||
# Clone the CLI Repo
|
# Clone the CLI Repo
|
||||||
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
|
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
|
||||||
|
|
||||||
# Move into the cli directory
|
# Move into the cli directory
|
||||||
cd xo-cli
|
cd xo-cli
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ npx tsx src/index.ts # TUI
|
|||||||
xo-cli mnemonic create
|
xo-cli mnemonic create
|
||||||
|
|
||||||
# Import an existing mnemonic seed phrase
|
# Import an existing mnemonic seed phrase
|
||||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
|
||||||
|
|
||||||
# List mnemonic basenames (use with -m)
|
# List mnemonic basenames (use with -m)
|
||||||
xo-cli mnemonic list
|
xo-cli mnemonic list
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
||||||
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return:
|
* eg: `xo-cli mnemonic create oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return:
|
||||||
* {
|
* {
|
||||||
* args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"],
|
* args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
|
||||||
* options: {
|
* options: {
|
||||||
* output: "mnemonic.txt",
|
* output: "mnemonic.txt",
|
||||||
* verbose: "true",
|
* verbose: "true",
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import { homedir } from "node:os";
|
|||||||
*
|
*
|
||||||
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
||||||
* - mnemonic.ts: create, import, list, expose
|
* - mnemonic.ts: create, import, list, expose
|
||||||
* - template.ts: import, list, inspect, set-default
|
* - template.ts: import, list, inspect, export, set-default
|
||||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
* - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list
|
||||||
* - resource.ts: list, unreserve, unreserve-all
|
* - resource.ts: list, unreserve, unreserve-all
|
||||||
* - settings.ts: show, get, set
|
* - settings.ts: show, get, set
|
||||||
*/
|
*/
|
||||||
@@ -43,7 +43,7 @@ import { homedir } from "node:os";
|
|||||||
/** Subcommands for the mnemonic command */
|
/** Subcommands for the mnemonic command */
|
||||||
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
||||||
/** Subcommands for the template command */
|
/** Subcommands for the template command */
|
||||||
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
|
const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"];
|
||||||
/** Subcommands for the invitation command */
|
/** Subcommands for the invitation command */
|
||||||
const INVITATION_SUBS = [
|
const INVITATION_SUBS = [
|
||||||
"create",
|
"create",
|
||||||
@@ -52,6 +52,7 @@ const INVITATION_SUBS = [
|
|||||||
"broadcast",
|
"broadcast",
|
||||||
"requirements",
|
"requirements",
|
||||||
"import",
|
"import",
|
||||||
|
"export",
|
||||||
"inspect",
|
"inspect",
|
||||||
"list",
|
"list",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
* and instead constructs the engine directly with an in-memory blockchain provider.
|
* and instead constructs the engine directly with an in-memory blockchain provider.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
BlockchainMonitor,
|
|
||||||
Engine,
|
|
||||||
InMemoryBlockchainProvider,
|
|
||||||
} from "@xo-cash/engine";
|
|
||||||
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
||||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
import { binToHex, hash256 } from "@bitauth/libauth";
|
import { binToHex, hash256 } from "@bitauth/libauth";
|
||||||
@@ -67,18 +63,21 @@ export async function createOfflineEngine(
|
|||||||
// Create the state instance
|
// Create the state instance
|
||||||
const state = new State(storageAdapter);
|
const state = new State(storageAdapter);
|
||||||
|
|
||||||
// Use in-memory blockchain provider (no network connections)
|
// Create a minimal blockchain monitor (no electrum initialization)
|
||||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
const blockchainMonitor = new BlockchainMonitor(state);
|
||||||
await blockchainProvider.initialize({
|
|
||||||
applicationIdentifier: "xo-cli-completions",
|
|
||||||
electrumOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a minimal blockchain monitor
|
// Engine constructor is private; bypass for offline read-only completions.
|
||||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
type EngineConstructor = new (
|
||||||
|
mnemonic: string,
|
||||||
|
state: State,
|
||||||
|
blockchainMonitor: BlockchainMonitor,
|
||||||
|
) => Engine;
|
||||||
|
|
||||||
// Construct engine directly without state sync
|
const engine = new (Engine as unknown as EngineConstructor)(
|
||||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
seed,
|
||||||
|
state,
|
||||||
|
blockchainMonitor,
|
||||||
|
);
|
||||||
|
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
append|sign|broadcast|requirements|inspect)
|
append|sign|broadcast|requirements|export|inspect)
|
||||||
# These subcommands expect an invitation identifier as first arg.
|
# These subcommands expect an invitation identifier as first arg.
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_
|
|||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from export; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
|
||||||
# invitation import <path>
|
# invitation import <path>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
append|sign|broadcast|requirements|inspect)
|
append|sign|broadcast|requirements|export|inspect)
|
||||||
# These subcommands take invitation ID as first argument.
|
# These subcommands take invitation ID as first argument.
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ import {
|
|||||||
resolveProvidedLockingBytecodeHex,
|
resolveProvidedLockingBytecodeHex,
|
||||||
mapUnspentOutputsToSelectable,
|
mapUnspentOutputsToSelectable,
|
||||||
autoSelectGreedyUtxos,
|
autoSelectGreedyUtxos,
|
||||||
|
hasMissingRequirements,
|
||||||
} from "../../utils/invitation-flow.js";
|
} from "../../utils/invitation-flow.js";
|
||||||
import { encodeExtendedJson } from "../../utils/ext-json.js";
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
import { resolveTemplate } from "../utils.js";
|
import { resolveTemplate } from "../utils.js";
|
||||||
|
|
||||||
const DEFAULT_FEE = 500n;
|
const DEFAULT_FEE = 500n;
|
||||||
const DUST_THRESHOLD = 546n;
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes an invitation to pretty-printed JSON for file export.
|
||||||
|
*/
|
||||||
|
const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string =>
|
||||||
|
JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of parsing CLI options into inputs and outputs for an append call.
|
* Result of parsing CLI options into inputs and outputs for an append call.
|
||||||
* A `null` return signals a fatal error that was already logged to stderr.
|
* A `null` return signals a fatal error that was already logged to stderr.
|
||||||
@@ -286,9 +294,13 @@ ${bold("Sub-commands:")}
|
|||||||
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
|
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
|
||||||
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
||||||
- import <invitation-file> ${dim("Import an invitation from a file")}
|
- import <invitation-file> ${dim("Import an invitation from a file")}
|
||||||
|
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or a file")}
|
||||||
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
||||||
- list ${dim("List all invitations")}
|
- list ${dim("List all invitations")}
|
||||||
|
|
||||||
|
${bold("Export options:")}
|
||||||
|
-o --output <output-filename> ${dim("Output filename for the exported invitation")}
|
||||||
|
|
||||||
${bold("Create / Append options:")}
|
${bold("Create / Append options:")}
|
||||||
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
||||||
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
||||||
@@ -311,6 +323,7 @@ export type InvitationCommandResult = {
|
|||||||
invitationIdentifier?: string;
|
invitationIdentifier?: string;
|
||||||
txHash?: string;
|
txHash?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
outputFile?: string;
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
actionIdentifier?: string;
|
actionIdentifier?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -320,6 +333,66 @@ export type InvitationCommandResult = {
|
|||||||
variables?: unknown[];
|
variables?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the invitation export command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after "export", e.g. ["invitation-id"] or ["invitation-id", "invitation.json"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
*/
|
||||||
|
export const handleInvitationExportCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ invitationIdentifier?: string; outputFile?: string }> => {
|
||||||
|
const invitationIdentifier = args[0];
|
||||||
|
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
|
||||||
|
|
||||||
|
if (!invitationIdentifier) {
|
||||||
|
deps.io.verbose("No invitation identifier provided");
|
||||||
|
printInvitationHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"invitation.export.identifier_missing",
|
||||||
|
"No invitation identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = deps.app.invitations.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"invitation.export.not_found",
|
||||||
|
`Invitation not found: ${invitationIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedInvitation = serializeInvitation(invitation.data);
|
||||||
|
|
||||||
|
const outputFile = options["output"] ?? args[1];
|
||||||
|
|
||||||
|
if (!outputFile) {
|
||||||
|
deps.io.out(serializedInvitation);
|
||||||
|
return { invitationIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
||||||
|
try {
|
||||||
|
writeFileSync(outputPath, serializedInvitation);
|
||||||
|
} catch (error) {
|
||||||
|
throw new CommandError(
|
||||||
|
"invitation.export.write_failed",
|
||||||
|
`Failed to export invitation to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.out(`Invitation exported to: ${outputPath}`);
|
||||||
|
return { invitationIdentifier, outputFile: outputPath };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the invitation command.
|
* Handles the invitation command.
|
||||||
* Throws CommandError on failure, returns result data on success.
|
* Throws CommandError on failure, returns result data on success.
|
||||||
@@ -380,7 +453,7 @@ export const handleInvitationCommand = async (
|
|||||||
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
|
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
|
||||||
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
||||||
deps.io.verbose(
|
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>`
|
// Read the variables that were passed in via `-var-<name> <value>`
|
||||||
@@ -401,6 +474,8 @@ export const handleInvitationCommand = async (
|
|||||||
|
|
||||||
// Append the inputs and outputs to the invitation
|
// Append the inputs and outputs to the invitation
|
||||||
const { inputs, outputs } = params;
|
const { inputs, outputs } = params;
|
||||||
|
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
|
||||||
|
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
|
||||||
if (inputs.length > 0 || outputs.length > 0) {
|
if (inputs.length > 0 || outputs.length > 0) {
|
||||||
await invitationInstance.append({ inputs, outputs });
|
await invitationInstance.append({ inputs, outputs });
|
||||||
}
|
}
|
||||||
@@ -411,7 +486,7 @@ export const handleInvitationCommand = async (
|
|||||||
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
invitationFilePath,
|
invitationFilePath,
|
||||||
encodeExtendedJson(invitationInstance.data, 2),
|
formatInvitationForFile(invitationInstance.data),
|
||||||
);
|
);
|
||||||
deps.io.out(
|
deps.io.out(
|
||||||
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
|
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
|
||||||
@@ -421,11 +496,11 @@ export const handleInvitationCommand = async (
|
|||||||
const missingRequirements =
|
const missingRequirements =
|
||||||
await invitationInstance.getMissingRequirements();
|
await invitationInstance.getMissingRequirements();
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
hasMissingRequirements(missingRequirements.templateRequirements) ||
|
||||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
missingRequirements.inputsMissingSignatures.length > 0;
|
||||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
|
||||||
(missingRequirements.roles !== undefined &&
|
deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`);
|
||||||
Object.keys(missingRequirements.roles).length > 0);
|
deps.io.verbose(`Has missing requirements: ${hasMissing}`);
|
||||||
|
|
||||||
// If there are missing requirements, print them out
|
// If there are missing requirements, print them out
|
||||||
if (hasMissing) {
|
if (hasMissing) {
|
||||||
@@ -437,6 +512,9 @@ export const handleInvitationCommand = async (
|
|||||||
options["sign"] === "true" || options["broadcast"] === "true";
|
options["sign"] === "true" || options["broadcast"] === "true";
|
||||||
const shouldBroadcast = 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
|
// Sign the invitation if the user has requested it
|
||||||
if (shouldSign) {
|
if (shouldSign) {
|
||||||
await invitationInstance.sign();
|
await invitationInstance.sign();
|
||||||
@@ -532,7 +610,10 @@ export const handleInvitationCommand = async (
|
|||||||
// Write the invitation to a file in the working directory
|
// Write the invitation to a file in the working directory
|
||||||
// TODO: Support the -o flag to specify the output path
|
// TODO: Support the -o flag to specify the output path
|
||||||
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
|
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
|
||||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
|
writeFileSync(
|
||||||
|
invitationFilePath,
|
||||||
|
formatInvitationForFile(invitation.data),
|
||||||
|
);
|
||||||
deps.io.out(
|
deps.io.out(
|
||||||
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
|
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
|
||||||
);
|
);
|
||||||
@@ -540,11 +621,8 @@ export const handleInvitationCommand = async (
|
|||||||
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
|
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
hasMissingRequirements(missingRequirements.templateRequirements) ||
|
||||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
missingRequirements.inputsMissingSignatures.length > 0;
|
||||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
|
||||||
(missingRequirements.roles !== undefined &&
|
|
||||||
Object.keys(missingRequirements.roles).length > 0);
|
|
||||||
|
|
||||||
// If there are missing requirements, print them out
|
// If there are missing requirements, print them out
|
||||||
if (hasMissing) {
|
if (hasMissing) {
|
||||||
@@ -721,11 +799,10 @@ export const handleInvitationCommand = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
|
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
|
||||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
const invitationFile = readFileSync(invitationFilePath, "utf8");
|
||||||
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||||
|
|
||||||
// Parse the invitation file
|
const invitation = deserializeInvitation(invitationFile);
|
||||||
const invitation = JSON.parse(invitationFile);
|
|
||||||
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||||
|
|
||||||
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
|
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
|
||||||
@@ -839,19 +916,27 @@ export const handleInvitationCommand = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
|
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
|
||||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
const invitationFile = readFileSync(invitationFilePath, "utf8");
|
||||||
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
deps.io.verbose(`Invitation file: ${invitationFile}`);
|
||||||
|
|
||||||
// Parse the invitation file
|
const invitation = deserializeInvitation(invitationFile);
|
||||||
const invitation = JSON.parse(invitationFile);
|
|
||||||
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
|
||||||
|
|
||||||
// "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in
|
const template = await deps.app.engine.getTemplate(
|
||||||
const xoInvitation = await deps.app.engine.createInvitation(invitation);
|
invitation.templateIdentifier,
|
||||||
deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`);
|
);
|
||||||
|
if (!template) {
|
||||||
|
throw new CommandError(
|
||||||
|
"invitation.import.template_not_found",
|
||||||
|
`Template not found: ${invitation.templateIdentifier}. ` +
|
||||||
|
`Import the matching template first with: xo-cli template import <template-file>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
|
// Accept and track the invitation. Invitation.create calls acceptInvitation
|
||||||
const invitationInstance = await deps.app.createInvitation(xoInvitation);
|
// internally — do not call engine.createInvitation here, that creates a new
|
||||||
|
// invitation and discards the imported commits.
|
||||||
|
const invitationInstance = await deps.app.createInvitation(invitation);
|
||||||
deps.io.verbose(
|
deps.io.verbose(
|
||||||
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
`Invitation created: ${formatObject(invitationInstance.data)}`,
|
||||||
);
|
);
|
||||||
@@ -862,6 +947,10 @@ export const handleInvitationCommand = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "export": {
|
||||||
|
return handleInvitationExportCommand(deps, args.slice(1), options);
|
||||||
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
// List all the invitations
|
// List all the invitations
|
||||||
const invitations = await Promise.all(
|
const invitations = await Promise.all(
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { hexToBin } from "@bitauth/libauth";
|
|||||||
|
|
||||||
import { bold, dim } from "../utils.js";
|
import { bold, dim } from "../utils.js";
|
||||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
import type { UnspentOutputData } from "@xo-cash/state";
|
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
import {
|
||||||
|
buildScriptHashDataMap,
|
||||||
|
enrichUnspentOutput,
|
||||||
|
type UnspentOutputWithMetadata,
|
||||||
|
} from "../../utils/utxo-metadata.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the resource command.
|
* Prints the help message for the resource command.
|
||||||
@@ -27,9 +33,12 @@ ${bold("Sub-commands:")}
|
|||||||
* Formats a single UTXO for display, optionally including reservation info.
|
* Formats a single UTXO for display, optionally including reservation info.
|
||||||
*/
|
*/
|
||||||
function formatResource(
|
function formatResource(
|
||||||
resource: UnspentOutputData,
|
resource: UnspentOutputWithMetadata & { template?: XOTemplate },
|
||||||
showReserved = false,
|
showReserved = false,
|
||||||
): string {
|
): string {
|
||||||
|
// Format the template
|
||||||
|
const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : "";
|
||||||
|
|
||||||
// Format the outpoint
|
// Format the outpoint
|
||||||
const outpoint = bold(
|
const outpoint = bold(
|
||||||
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||||
@@ -39,7 +48,9 @@ function formatResource(
|
|||||||
const value = dim(`${resource.valueSatoshis} sats`);
|
const value = dim(`${resource.valueSatoshis} sats`);
|
||||||
|
|
||||||
// Format the output
|
// Format the output
|
||||||
const output = dim(resource.outputIdentifier);
|
const output = resource.outputIdentifier
|
||||||
|
? dim(resource.outputIdentifier)
|
||||||
|
: "";
|
||||||
|
|
||||||
// Format the height
|
// Format the height
|
||||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||||
@@ -47,11 +58,11 @@ function formatResource(
|
|||||||
// If the resource is reserved, format the reservation info
|
// If the resource is reserved, format the reservation info
|
||||||
if (showReserved && resource.reservedBy) {
|
if (showReserved && resource.reservedBy) {
|
||||||
const inv = dim(`reserved for ${resource.reservedBy}`);
|
const inv = dim(`reserved for ${resource.reservedBy}`);
|
||||||
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, format the resource without reservation info
|
// Otherwise, format the resource without reservation info
|
||||||
return `${outpoint} ${value} ${output} ${height}`;
|
return `${template} ${outpoint} ${value} ${output} ${height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,9 +119,30 @@ export const handleResourceCommand = async (
|
|||||||
return { count: 0 };
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scriptHashDataByScriptHash = await buildScriptHashDataMap(
|
||||||
|
deps.app.engine,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourcesWithTemplateInformation = await Promise.all(
|
||||||
|
filtered.map(async (resource) => {
|
||||||
|
const enriched = enrichUnspentOutput(
|
||||||
|
resource,
|
||||||
|
scriptHashDataByScriptHash,
|
||||||
|
);
|
||||||
|
const template = enriched.templateIdentifier
|
||||||
|
? await deps.app.engine.getTemplate(enriched.templateIdentifier)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enriched,
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Format the resources into a list of strings that we can display to the user
|
// Format the resources into a list of strings that we can display to the user
|
||||||
const showReserved = qualifier === "all" || qualifier === "reserved";
|
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||||
const formattedResources = filtered.map((r) =>
|
const formattedResources = resourcesWithTemplateInformation.map((r) =>
|
||||||
formatResource(r, showReserved),
|
formatResource(r, showReserved),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, writeFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import type { XOTemplate } from "@xo-cash/types";
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
import { bold, dim, formatObject } from "../utils.js";
|
import { bold, dim, formatObject } from "../utils.js";
|
||||||
|
import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js";
|
||||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
@@ -18,11 +19,15 @@ export const printTemplateHelp = (io: CommandIO): void => {
|
|||||||
${bold("Usage:")} xo-cli template <sub-command>
|
${bold("Usage:")} xo-cli template <sub-command>
|
||||||
|
|
||||||
${bold("Sub-commands:")}
|
${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 ${dim("List all templates")}
|
||||||
- list <category> <identifier> ${dim("List all options of the field type in a template")}
|
- 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")}
|
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
|
||||||
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
||||||
|
- export <template-identifier> [output-file] ${dim("Export a template to stdout or a file")}
|
||||||
|
|
||||||
|
${bold("Options:")}
|
||||||
|
-o --output <output-filename> ${dim("Output filename for the exported template")}
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -338,6 +343,71 @@ export const handleTemplateInspectCommand = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the template export command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after "export", e.g. ["template-id"] or ["template-id", "template.json"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
*/
|
||||||
|
export const handleTemplateExportCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ outputFile?: string }> => {
|
||||||
|
// Get the template identifier from the arguments
|
||||||
|
const templateIdentifier = args[0];
|
||||||
|
|
||||||
|
// If no template identifier is provided, print a message and throw an error
|
||||||
|
if (!templateIdentifier) {
|
||||||
|
deps.io.err("No template identifier provided");
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.identifier_missing",
|
||||||
|
"No template identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw template from the engine.
|
||||||
|
// Do not resolve references or pretty-print the template.
|
||||||
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
|
|
||||||
|
// If the raw template is not found, print a message and throw an error
|
||||||
|
if (!rawTemplate) {
|
||||||
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.not_found",
|
||||||
|
`No template found: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the template without indentation to preserve the engine output shape.
|
||||||
|
const serializedTemplate = JSON.stringify(rawTemplate);
|
||||||
|
|
||||||
|
// Resolve output file from --output (or -o), then fallback to optional positional output file
|
||||||
|
const outputFile = options["output"] ?? args[1];
|
||||||
|
|
||||||
|
// If no output file is provided, print the template to stdout
|
||||||
|
if (!outputFile) {
|
||||||
|
deps.io.out(serializedTemplate);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve output file path and write the template to disk
|
||||||
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
||||||
|
try {
|
||||||
|
writeFileSync(outputPath, serializedTemplate);
|
||||||
|
} catch (error) {
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.write_failed",
|
||||||
|
`Failed to export template to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.out(`Template exported to: ${outputPath}`);
|
||||||
|
return { outputFile: outputPath };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the template command.
|
* Handles the template command.
|
||||||
* Throws CommandError on failure, returns result data on success.
|
* Throws CommandError on failure, returns result data on success.
|
||||||
@@ -348,8 +418,8 @@ export const handleTemplateInspectCommand = async (
|
|||||||
export const handleTemplateCommand = async (
|
export const handleTemplateCommand = async (
|
||||||
deps: CommandDependencies,
|
deps: CommandDependencies,
|
||||||
args: string[],
|
args: string[],
|
||||||
_options: Record<string, string>,
|
options: Record<string, string>,
|
||||||
): Promise<{ templateFile?: string; count?: number }> => {
|
): Promise<{ templateFile?: string; count?: number; outputFile?: string }> => {
|
||||||
// Get the sub-command from the arguments
|
// Get the sub-command from the arguments
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
|
|
||||||
@@ -395,12 +465,26 @@ export const handleTemplateCommand = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the template file
|
// Read and load the template file (JSON directly, TS/JS via child process).
|
||||||
const template = await readFileSync(templatePath, "utf8");
|
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}`);
|
deps.io.verbose(`Importing template: ${templateFile}`);
|
||||||
|
|
||||||
// Import the template
|
// Import the template
|
||||||
await deps.app.engine.importTemplate(template);
|
await deps.app.engine.importTemplate(templateContents);
|
||||||
deps.io.verbose(`Template imported: ${templateFile}`);
|
deps.io.verbose(`Template imported: ${templateFile}`);
|
||||||
|
|
||||||
// Return the template file
|
// Return the template file
|
||||||
@@ -414,6 +498,10 @@ export const handleTemplateCommand = async (
|
|||||||
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
|
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
|
||||||
return handleTemplateInspectCommand(deps, args.slice(1));
|
return handleTemplateInspectCommand(deps, args.slice(1));
|
||||||
}
|
}
|
||||||
|
case "export": {
|
||||||
|
// Handle the template export command
|
||||||
|
return handleTemplateExportCommand(deps, args.slice(1), options);
|
||||||
|
}
|
||||||
case "set-default": {
|
case "set-default": {
|
||||||
// Get the template file, output identifier, and role identifier from the arguments
|
// Get the template file, output identifier, and role identifier from the arguments
|
||||||
const templateFile = args[1];
|
const templateFile = args[1];
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import { EventEmitter } from "../utils/event-emitter.js";
|
|||||||
|
|
||||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
|
||||||
import { hexToBin } from "@bitauth/libauth";
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
import { parseTemplate } from "@xo-cash/engine";
|
import { parseTemplate } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||||
|
import { vendingMachineTemplate } from "../templates/vending-machine.js";
|
||||||
|
import { wrapBCHTemplate } from "../templates/wrap-template.js";
|
||||||
|
|
||||||
export type AppEventMap = {
|
export type AppEventMap = {
|
||||||
"invitation-added": Invitation;
|
"invitation-added": Invitation;
|
||||||
"invitation-removed": Invitation;
|
"invitation-removed": Invitation;
|
||||||
@@ -53,6 +56,12 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public settings: SettingsService;
|
public settings: SettingsService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
/**
|
||||||
|
* Incremented whenever the invitation list or any invitation's data/status changes.
|
||||||
|
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
|
||||||
|
*/
|
||||||
|
public invitationsRevision = 0;
|
||||||
|
private invitationRevisions = new Map<string, number>();
|
||||||
private invitationEventCleanup = new Map<
|
private invitationEventCleanup = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -81,22 +90,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||||
// Import the default P2PKH template
|
// Import the default P2PKH template
|
||||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
await engine.importTemplate(vendingMachineTemplate);
|
||||||
// engine
|
await engine.importTemplate(wrapBCHTemplate);
|
||||||
// .subscribeToLockingBytecodesForTemplate(templateIdentifier)
|
|
||||||
// .catch((err) =>
|
|
||||||
// console.error(
|
|
||||||
// `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// engine
|
|
||||||
// .updateUnspentOutputsForTemplate(templateIdentifier)
|
|
||||||
// .catch((err) =>
|
|
||||||
// console.error(
|
|
||||||
// `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
|
|
||||||
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||||
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||||
@@ -104,8 +100,8 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
const templates = await engine.listImportedTemplates();
|
const templates = await engine.listImportedTemplates();
|
||||||
|
|
||||||
templates.forEach(async (template) => {
|
templates.forEach(async (template) => {
|
||||||
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
|
engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
|
||||||
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
|
engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,8 +171,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = await Invitation.create(invitation, deps);
|
const invitationInstance = await Invitation.create(invitation, deps);
|
||||||
|
|
||||||
// Add the invitation to the invitations array
|
// Attach listeners before SSE connects so updates are not missed.
|
||||||
await this.addInvitation(invitationInstance);
|
await this.addInvitation(invitationInstance);
|
||||||
|
await invitationInstance.start();
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
|
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
|
||||||
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
||||||
import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state";
|
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
XOInvitationCommit,
|
|
||||||
XOInvitationInput,
|
XOInvitationInput,
|
||||||
XOInvitationOutput,
|
XOInvitationOutput,
|
||||||
XOInvitationVariableValue,
|
XOInvitationVariableValue,
|
||||||
@@ -146,8 +145,10 @@ export class HistoryService {
|
|||||||
const contexts = new Map<string, InvitationContext>();
|
const contexts = new Map<string, InvitationContext>();
|
||||||
|
|
||||||
for (const invitation of this.invitations) {
|
for (const invitation of this.invitations) {
|
||||||
const template =
|
const templateIdentifier = invitation.data.templateIdentifier;
|
||||||
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null;
|
const template = templateIdentifier
|
||||||
|
? (await this.engine.getTemplate(templateIdentifier)) ?? null
|
||||||
|
: null;
|
||||||
contexts.set(invitation.data.invitationIdentifier, {
|
contexts.set(invitation.data.invitationIdentifier, {
|
||||||
invitation,
|
invitation,
|
||||||
template,
|
template,
|
||||||
@@ -164,11 +165,19 @@ export class HistoryService {
|
|||||||
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
||||||
const templateIdentifiers = new Set<string>();
|
const templateIdentifiers = new Set<string>();
|
||||||
|
|
||||||
for (const utxo of allUtxos) {
|
|
||||||
templateIdentifiers.add(utxo.templateIdentifier);
|
|
||||||
}
|
|
||||||
for (const invitation of this.invitations) {
|
for (const invitation of this.invitations) {
|
||||||
templateIdentifiers.add(invitation.data.templateIdentifier);
|
if (invitation.data.templateIdentifier) {
|
||||||
|
templateIdentifiers.add(invitation.data.templateIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueScriptHashes = new Set(allUtxos.map((utxo) => utxo.scriptHash));
|
||||||
|
for (const scriptHash of uniqueScriptHashes) {
|
||||||
|
const scriptHashData = await this.getScriptHashData(scriptHash);
|
||||||
|
if (scriptHashData === undefined) continue;
|
||||||
|
|
||||||
|
scriptHashDataByScriptHash.set(scriptHash, scriptHashData);
|
||||||
|
templateIdentifiers.add(scriptHashData.templateIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const templateIdentifier of templateIdentifiers) {
|
for (const templateIdentifier of templateIdentifiers) {
|
||||||
@@ -186,8 +195,10 @@ export class HistoryService {
|
|||||||
metadataIndex: WalletMetadataIndex,
|
metadataIndex: WalletMetadataIndex,
|
||||||
): Promise<UtxoContext> {
|
): Promise<UtxoContext> {
|
||||||
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
|
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier;
|
const templateIdentifier = scriptHashData?.templateIdentifier;
|
||||||
const template = (await this.engine.getTemplate(templateIdentifier)) ?? null;
|
const template = templateIdentifier
|
||||||
|
? (await this.engine.getTemplate(templateIdentifier)) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
utxo,
|
utxo,
|
||||||
@@ -299,7 +310,7 @@ export class HistoryService {
|
|||||||
if (!matchingContext) continue;
|
if (!matchingContext) continue;
|
||||||
|
|
||||||
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
|
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
|
||||||
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier;
|
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier;
|
||||||
const role =
|
const role =
|
||||||
output.roleIdentifier ??
|
output.roleIdentifier ??
|
||||||
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
|
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
|
||||||
@@ -380,20 +391,21 @@ export class HistoryService {
|
|||||||
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
|
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
|
||||||
if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
|
if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
|
||||||
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true;
|
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true;
|
||||||
if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true;
|
|
||||||
|
if (output.outputIdentifier && context.scriptHashData?.outputIdentifier === output.outputIdentifier) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
|
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
|
||||||
const output = this.projectUtxoOutput(context);
|
const output = this.projectUtxoOutput(context);
|
||||||
const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier;
|
const templateIdentifier = context.scriptHashData?.templateIdentifier;
|
||||||
const role = output.role;
|
const role = output.role;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
|
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
|
||||||
source: "utxo",
|
source: "utxo",
|
||||||
templateIdentifier,
|
templateIdentifier: templateIdentifier ?? "",
|
||||||
template: context.template?.name ?? "UnknownTemplate",
|
template: context.template?.name ?? "UnknownTemplate",
|
||||||
roles: role ? [role] : ["unknown"],
|
roles: role ? [role] : ["unknown"],
|
||||||
description: output.description,
|
description: output.description,
|
||||||
@@ -404,7 +416,7 @@ export class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
|
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
|
||||||
const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier;
|
const outputIdentifier = context.scriptHashData?.outputIdentifier;
|
||||||
const role = context.scriptHashData?.roleIdentifier;
|
const role = context.scriptHashData?.roleIdentifier;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -557,7 +569,7 @@ export class HistoryService {
|
|||||||
variables: Record<string, XOInvitationVariableValue>,
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
): string {
|
): string {
|
||||||
try {
|
try {
|
||||||
return compileCashAssemblyString(description, variables);
|
return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' });
|
||||||
} catch {
|
} catch {
|
||||||
return this.interpolateSimpleCashAssemblyVariables(description, variables);
|
return this.interpolateSimpleCashAssemblyVariables(description, variables);
|
||||||
}
|
}
|
||||||
@@ -591,6 +603,10 @@ export class HistoryService {
|
|||||||
: binToHex(output.lockingBytecode);
|
: binToHex(output.lockingBytecode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
|
||||||
|
return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash);
|
||||||
|
}
|
||||||
|
|
||||||
private getOutpointKey(txid: string, index: number): string {
|
private getOutpointKey(txid: string, index: number): string {
|
||||||
return `${txid}:${index}`;
|
return `${txid}:${index}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
AcceptInvitationParameters,
|
InvitationParameters,
|
||||||
AppendInvitationParameters,
|
|
||||||
Engine,
|
Engine,
|
||||||
GetSpendableResourcesParameters,
|
GetSpendableResourcesParameters,
|
||||||
} from "@xo-cash/engine";
|
} from "@xo-cash/engine";
|
||||||
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine";
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
XOInvitationCommit,
|
XOInvitationCommit,
|
||||||
@@ -86,13 +85,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// engine invitation (I have no idea if this is required)
|
// engine invitation (I have no idea if this is required)
|
||||||
const engineInvitation = await dependencies.engine.acceptInvitation(invitation);
|
const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation));
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
const invitationInstance = new Invitation(engineInvitation, dependencies);
|
||||||
|
|
||||||
// Start the invitation and its tracking
|
// Start the invitation and its tracking
|
||||||
await invitationInstance.start();
|
invitationInstance.start();
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
@@ -150,6 +149,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Start the invitation - Connect sync server and download latest invitation data.
|
* Start the invitation - Connect sync server and download latest invitation data.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Persist immediately so imports survive sync-server outages and appear in the TUI
|
||||||
|
// after a CLI import or app restart.
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
@@ -279,7 +282,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
private async computeStatusInternal(): Promise<string> {
|
private async computeStatusInternal(): Promise<string> {
|
||||||
let missingReqs;
|
let missingReqs;
|
||||||
try {
|
try {
|
||||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier);
|
||||||
|
missingReqs = missingRequirements.templateRequirements;
|
||||||
} catch {
|
} catch {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -378,7 +382,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Accept the invitation
|
* Accept the invitation
|
||||||
*/
|
*/
|
||||||
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
async accept(acceptParams?: InvitationParameters): Promise<void> {
|
||||||
// Accept the invitation
|
// Accept the invitation
|
||||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||||
|
|
||||||
@@ -394,7 +398,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
*/
|
*/
|
||||||
async sign(): Promise<void> {
|
async sign(): Promise<void> {
|
||||||
// Sign the invitation
|
// Sign the invitation
|
||||||
const signedInvitation = await this.engine.signInvitation(this.data);
|
const signedInvitation = await this.engine.signInvitation(this.data.invitationIdentifier);
|
||||||
|
|
||||||
// Publish the signed invitation to the sync server
|
// Publish the signed invitation to the sync server
|
||||||
this.publishInvitation(signedInvitation);
|
this.publishInvitation(signedInvitation);
|
||||||
@@ -413,7 +417,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* @returns The transaction hash returned by the network after broadcast.
|
* @returns The transaction hash returned by the network after broadcast.
|
||||||
*/
|
*/
|
||||||
async broadcast(): Promise<string> {
|
async broadcast(): Promise<string> {
|
||||||
const txHash = await this.engine.executeAction(this.data, {
|
const txHash = await this.engine.executeAction(this.data.invitationIdentifier, {
|
||||||
broadcastTransaction: true,
|
broadcastTransaction: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,9 +433,15 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Append a commit to the invitation
|
* Append a commit to the invitation
|
||||||
*/
|
*/
|
||||||
async append(data: AppendInvitationParameters): Promise<void> {
|
async append(data: InvitationParameters): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.engine.acceptInvitation(this.data);
|
||||||
|
} catch (err) {
|
||||||
|
// Literally do nothing here. We are just trying to accept the invitation in case we haven't already
|
||||||
|
}
|
||||||
|
|
||||||
// Append the commit to the invitation
|
// Append the commit to the invitation
|
||||||
this.data = await this.engine.appendInvitation(this.data, data);
|
this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
@@ -546,7 +556,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Get the missing requirements for the invitation
|
* Get the missing requirements for the invitation
|
||||||
*/
|
*/
|
||||||
async getMissingRequirements() {
|
async getMissingRequirements() {
|
||||||
return this.engine.listMissingRequirements(this.data);
|
return this.engine.listMissingRequirements(this.data.invitationIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -608,8 +618,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueSatoshisIdentifier = output.valueSatoshis;
|
const valueSatoshisExpression = output.valueSatoshis;
|
||||||
if (!valueSatoshisIdentifier) {
|
if (!valueSatoshisExpression) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
);
|
);
|
||||||
@@ -623,16 +633,16 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Create a dictionary of the variables
|
// Create a dictionary of the variables
|
||||||
const formattedVariables = variables.reduce(
|
const formattedVariables = variables.reduce(
|
||||||
(acc, v) => {
|
(acc, v) => {
|
||||||
acc[v.variableIdentifier ?? ""] = v.value;
|
const { variableIdentifier, value } = v;
|
||||||
|
acc[variableIdentifier ?? ""] = value;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, XOInvitationVariableValue>,
|
{} as Record<string, XOInvitationVariableValue>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||||
const valueSatoshis = await compileCashAssemblyString(
|
const valueSatoshis = compileCashAssemblyString(
|
||||||
String(valueSatoshisIdentifier),
|
{ cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' },
|
||||||
formattedVariables,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return the value satoshis as a bigint
|
// Return the value satoshis as a bigint
|
||||||
@@ -688,9 +698,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Iterate through the outputs and sum the valueSatoshis
|
// Iterate through the outputs and sum the valueSatoshis
|
||||||
for (const output of outputs) {
|
for (const output of outputs) {
|
||||||
if (typeof output === "string") {
|
if (typeof output === "string") {
|
||||||
totalSats += await this.getSatsOut(output);
|
const sats = await this.getSatsOut(output);
|
||||||
|
totalSats += sats
|
||||||
} else {
|
} else {
|
||||||
totalSats += await this.getSatsOut(output.output);
|
const sats = await this.getSatsOut(output.output);
|
||||||
|
totalSats += sats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OracleClient } from '@generalprotocols/oracle-client';
|
||||||
import { EventEmitter } from '../utils/event-emitter.js';
|
import { EventEmitter } from '../utils/event-emitter.js';
|
||||||
import {
|
import {
|
||||||
type RatesEventMap,
|
type RatesEventMap,
|
||||||
@@ -73,8 +74,17 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
|||||||
settings: SettingsService,
|
settings: SettingsService,
|
||||||
adapter?: RatesAdapter,
|
adapter?: RatesAdapter,
|
||||||
): Promise<RatesService> {
|
): Promise<RatesService> {
|
||||||
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
|
if (adapter) {
|
||||||
return new RatesService(resolvedAdapter, settings);
|
return new RatesService(adapter, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oracleClient = new OracleClient();
|
||||||
|
oracleClient.start();
|
||||||
|
|
||||||
|
const ratesOracle = new RatesOracle(oracleClient, settings);
|
||||||
|
ratesOracle.start();
|
||||||
|
|
||||||
|
return new RatesService(ratesOracle, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
||||||
@@ -56,9 +57,8 @@ export class Storage extends BaseStorage {
|
|||||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
// Encode the extended json object
|
const encodedValue = serializeInvitation(value);
|
||||||
const encodedValue = encodeExtendedJson(value);
|
|
||||||
|
|
||||||
// Insert or replace the value into the database with full key (including basePath)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
@@ -93,10 +93,10 @@ export class Storage extends BaseStorage {
|
|||||||
return !strippedKey.includes(".");
|
return !strippedKey.includes(".");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Decode the extended json objects and strip basePath from keys
|
// Deserialize invitations and strip basePath from keys
|
||||||
return filteredRows.map((row) => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value),
|
value: deserializeInvitation(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export class Storage extends BaseStorage {
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
// Decode the extended json object
|
// Decode the extended json object
|
||||||
return decodeExtendedJson(row.value);
|
return deserializeInvitation(row.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
@@ -174,9 +174,9 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const encodedValue = encodeExtendedJson(value);
|
const encodedValue = serializeInvitation(value);
|
||||||
this.store.set(fullKey, encodedValue);
|
this.store.set(fullKey, encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
|
|
||||||
return filteredRows.map((row) => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value),
|
value: deserializeInvitation(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
const encodedValue = this.store.get(fullKey);
|
const encodedValue = this.store.get(fullKey);
|
||||||
if (encodedValue === undefined) return null;
|
if (encodedValue === undefined) return null;
|
||||||
|
|
||||||
return decodeExtendedJson(encodedValue);
|
return deserializeInvitation(encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
|
|||||||
277
src/templates/vending-machine.ts
Normal file
277
src/templates/vending-machine.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vending machine payment template.
|
||||||
|
*
|
||||||
|
* Merchant creates a purchaseItems invitation with receipt variables;
|
||||||
|
* customer funds and signs the composable transaction.
|
||||||
|
*/
|
||||||
|
export const vendingMachineTemplate: XOTemplate = {
|
||||||
|
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
|
||||||
|
name: 'Vending Machine',
|
||||||
|
description: 'Purchase items from a vending machine with an itemized receipt.',
|
||||||
|
icon: 'wallet',
|
||||||
|
version: '1',
|
||||||
|
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
|
||||||
|
|
||||||
|
defaults: {
|
||||||
|
change: {
|
||||||
|
output: 'changeOutput',
|
||||||
|
role: 'merchant',
|
||||||
|
generate: ['merchantKey'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: 'Merchant',
|
||||||
|
description: 'The vending machine operator receiving payment.',
|
||||||
|
icon: 'owner',
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: 'Customer',
|
||||||
|
description: 'The customer paying for items.',
|
||||||
|
icon: 'sender',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
action: 'purchaseItems',
|
||||||
|
role: 'merchant',
|
||||||
|
generate: ['merchantKey'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
purchaseItems: {
|
||||||
|
name: 'Purchase Items',
|
||||||
|
description: 'Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats',
|
||||||
|
icon: 'request',
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: 'Sell Items',
|
||||||
|
description: 'Receive payment for $(<receiptSummary>)',
|
||||||
|
icon: 'request',
|
||||||
|
requirements: {
|
||||||
|
secrets: ['merchantKey'],
|
||||||
|
variables: [
|
||||||
|
'totalSatoshis',
|
||||||
|
'orderId',
|
||||||
|
'merchantName',
|
||||||
|
'receiptSummary',
|
||||||
|
'lineItemsJson',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: 'Pay',
|
||||||
|
description: 'Pay $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||||
|
icon: 'send',
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [
|
||||||
|
{ role: 'merchant', slots: { min: 1, max: 1 } },
|
||||||
|
{ role: 'customer', slots: { min: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: 'purchaseItemsTransaction',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
transactions: {
|
||||||
|
purchaseItemsTransaction: {
|
||||||
|
name: 'Vending Purchase',
|
||||||
|
description: 'Order $(<orderId>): $(<receiptSummary>)',
|
||||||
|
icon: 'request',
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: 'Received Payment',
|
||||||
|
description: 'Received $(<totalSatoshis>) sats from $(<merchantName>) sale',
|
||||||
|
icon: 'receive',
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: 'Sent Payment',
|
||||||
|
description: 'Paid $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||||
|
icon: 'send',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ output: 'purchaseOutput' }],
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||||
|
inputs: {},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
changeOutput: {
|
||||||
|
name: 'Change',
|
||||||
|
description: 'Funds returned as change.',
|
||||||
|
icon: 'receive',
|
||||||
|
lockingScript: 'merchantReceivingLockingScript',
|
||||||
|
},
|
||||||
|
purchaseOutput: {
|
||||||
|
name: 'Purchase Payment',
|
||||||
|
description: '$(<totalSatoshis>) sats to $(<merchantName>)',
|
||||||
|
icon: 'request',
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: 'Payment Received',
|
||||||
|
description: 'Received $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: 'Payment Sent',
|
||||||
|
description: 'Sent $(<totalSatoshis>) sats for $(<receiptSummary>)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: 'merchantReceivingLockingScript',
|
||||||
|
valueSatoshis: '$(<totalSatoshis>)',
|
||||||
|
token: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScripts: {
|
||||||
|
merchantReceivingLockingScript: {
|
||||||
|
name: 'Merchant Receive',
|
||||||
|
description: 'Funds received by the vending machine merchant.',
|
||||||
|
icon: 'address',
|
||||||
|
lockingType: 'p2pkh',
|
||||||
|
lockingBytecode: 'lockMerchantP2PKH',
|
||||||
|
unlockingBytecode: 'unlockMerchantP2PKH',
|
||||||
|
actions: [],
|
||||||
|
state: { variables: [], secrets: [] },
|
||||||
|
balance: {},
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
state: {
|
||||||
|
variables: [],
|
||||||
|
secrets: ['merchantKey'],
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
scripts: {
|
||||||
|
lockMerchantP2PKH:
|
||||||
|
'OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
|
||||||
|
unlockMerchantP2PKH:
|
||||||
|
'<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>',
|
||||||
|
},
|
||||||
|
|
||||||
|
constants: {
|
||||||
|
dustLimit: {
|
||||||
|
name: 'Dust Limit',
|
||||||
|
description: 'Minimum satoshis for P2PKH outputs.',
|
||||||
|
type: 'integer',
|
||||||
|
value: 546,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variables: {
|
||||||
|
merchantKey: {
|
||||||
|
name: 'Merchant Private Key',
|
||||||
|
description: 'Private key for the vending machine merchant wallet.',
|
||||||
|
type: 'bytes',
|
||||||
|
hint: 'private_key',
|
||||||
|
},
|
||||||
|
totalSatoshis: {
|
||||||
|
name: 'Total Price',
|
||||||
|
description: 'Total purchase price in satoshis',
|
||||||
|
type: 'integer',
|
||||||
|
hint: 'satoshis',
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
name: 'Order ID',
|
||||||
|
description: 'Unique order identifier',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
merchantName: {
|
||||||
|
name: 'Merchant Name',
|
||||||
|
description: 'Display name of the vending machine',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
receiptSummary: {
|
||||||
|
name: 'Receipt Summary',
|
||||||
|
description: 'Human-readable list of purchased items',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
lineItemsJson: {
|
||||||
|
name: 'Line Items',
|
||||||
|
description: 'JSON-encoded line items for the purchase',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: [
|
||||||
|
{ name: 'wallet', hash: '0000000000000000000000' },
|
||||||
|
{ name: 'owner', hash: '0000000000000000000000' },
|
||||||
|
{ name: 'sender', hash: '0000000000000000000000' },
|
||||||
|
{ name: 'request', hash: '0000000000000000000000' },
|
||||||
|
{ name: 'receive', hash: '0000000000000000000000' },
|
||||||
|
{ name: 'send', hash: '0000000000000000000000' },
|
||||||
|
],
|
||||||
|
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
name: 'purchase items happy path',
|
||||||
|
description: 'Merchant requests payment for vending machine items.',
|
||||||
|
action: 'purchaseItems',
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
role: 'merchant',
|
||||||
|
values: {
|
||||||
|
generated: {
|
||||||
|
merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8',
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
totalSatoshis: 3500,
|
||||||
|
orderId: 'order-demo-1',
|
||||||
|
merchantName: 'XO Snack Machine',
|
||||||
|
receiptSummary: '2× Cola, 1× Chips',
|
||||||
|
lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||||
|
},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac',
|
||||||
|
valueSatoshis: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'customer',
|
||||||
|
values: {
|
||||||
|
generated: {},
|
||||||
|
variables: {},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
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.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useId, useRef, useState } from 'react';
|
import React, { useId, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, measureElement } from 'ink';
|
import { Box, Text, measureElement, useStdout } from 'ink';
|
||||||
import TextInput from './TextInput.js';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.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.
|
* Base dialog wrapper props.
|
||||||
@@ -261,6 +267,23 @@ export function MessageDialog({
|
|||||||
isActive = true,
|
isActive = true,
|
||||||
}: MessageDialogProps): React.ReactElement {
|
}: MessageDialogProps): React.ReactElement {
|
||||||
const layerId = useId();
|
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.
|
// Auto-capture input when this dialog is mounted.
|
||||||
useInputLayer(layerId);
|
useInputLayer(layerId);
|
||||||
@@ -269,7 +292,7 @@ export function MessageDialog({
|
|||||||
if (key.return || key.escape) {
|
if (key.return || key.escape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
});
|
}, { isActive });
|
||||||
|
|
||||||
const borderColor = type === 'error' ? colors.error :
|
const borderColor = type === 'error' ? colors.error :
|
||||||
type === 'success' ? colors.success :
|
type === 'success' ? colors.success :
|
||||||
@@ -280,8 +303,16 @@ export function MessageDialog({
|
|||||||
'ℹ';
|
'ℹ';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
|
<DialogWrapper
|
||||||
<Text wrap="wrap">{message}</Text>
|
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}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
</Box>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export function AppProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start the AppService (loads existing invitations)
|
// Start the AppService (loads existing invitations)
|
||||||
await service.start();
|
service.start();
|
||||||
|
|
||||||
// Set the service and mark as initialized
|
// Set the service and mark as initialized
|
||||||
setAppService(service);
|
setAppService(service);
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setSeedPhrase('');
|
setSeedPhrase('');
|
||||||
setSaveMnemonicChecked(false);
|
setSaveMnemonicChecked(false);
|
||||||
|
|
||||||
setTimeout(() => {
|
navigate('wallet');
|
||||||
navigate('wallet');
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import path from 'node:path';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
|
import { FilePicker } from '../components/FilePicker.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.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';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
|
|
||||||
// XO Imports
|
// XO Imports
|
||||||
@@ -24,6 +26,9 @@ import {
|
|||||||
formatActionListItem,
|
formatActionListItem,
|
||||||
getTemplateRoles,
|
getTemplateRoles,
|
||||||
} from '../../utils/template-utils.js';
|
} 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.
|
* Template item with metadata.
|
||||||
@@ -52,6 +57,55 @@ interface TemplateActionItem {
|
|||||||
source: 'starting' | 'next' | 'starting+next';
|
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.
|
* Template List Screen Component.
|
||||||
* Displays templates and their starting actions.
|
* Displays templates and their starting actions.
|
||||||
@@ -67,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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.
|
* Loads templates from the engine.
|
||||||
@@ -83,12 +141,21 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const templateList = await appService.engine.listImportedTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
const allUtxos = await appService.engine.listUnspentOutputsData();
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||||
|
const scriptHashDataByScriptHash =
|
||||||
|
await buildScriptHashDataMap(appService.engine);
|
||||||
|
|
||||||
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||||
for (const utxo of allUtxos) {
|
for (const utxo of allUtxos) {
|
||||||
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
existing.add(utxo.outputIdentifier);
|
if (scriptRow === undefined) {
|
||||||
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ??
|
||||||
|
new Set<string>();
|
||||||
|
existing.add(scriptRow.outputIdentifier);
|
||||||
|
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
@@ -186,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
// Get current template and its actions
|
|
||||||
const currentTemplate = templates[selectedTemplateIndex];
|
|
||||||
const currentActions = currentTemplate?.availableActions ?? [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build template list items for ScrollableList.
|
* Build template list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const templateListItems = useMemo((): TemplateListItem[] => {
|
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);
|
const formatted = formatTemplateListItem(item.template, index);
|
||||||
return {
|
return {
|
||||||
key: item.templateIdentifier,
|
key: item.templateIdentifier,
|
||||||
@@ -203,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
value: item,
|
value: item,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
};
|
};
|
||||||
});
|
}), importTemplateItem];
|
||||||
}, [templates]);
|
}, [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.
|
* Build action list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
@@ -236,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
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.
|
* Handles action selection.
|
||||||
* Navigates to the Action Wizard where the user will choose their role.
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
@@ -254,12 +416,25 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [currentTemplate, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation and template shortcuts
|
||||||
useBlockableInput((_input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading || focusedPanel !== 'templates') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'a' || input === 'A') {
|
||||||
|
openImportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((input === 'd' || input === 'D') && currentTemplate) {
|
||||||
|
openDeleteDialog();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
items={templateListItems}
|
items={templateListItems}
|
||||||
selectedIndex={selectedTemplateIndex}
|
selectedIndex={selectedTemplateIndex}
|
||||||
onSelect={handleTemplateSelect}
|
onSelect={handleTemplateSelect}
|
||||||
focus={focusedPanel === 'templates'}
|
onActivate={handleTemplateActivate}
|
||||||
|
focus={focusedPanel === 'templates' && !isCaptured}
|
||||||
emptyMessage="No templates imported"
|
emptyMessage="No templates imported"
|
||||||
renderItem={renderTemplateItem}
|
renderItem={renderTemplateItem}
|
||||||
/>
|
/>
|
||||||
@@ -350,6 +526,12 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isImportRowSelected ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Import a template to see available actions
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
) : !currentTemplate ? (
|
) : !currentTemplate ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Select a template...</Text>
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
@@ -360,7 +542,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedActionIndex}
|
selectedIndex={selectedActionIndex}
|
||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionActivate}
|
onActivate={handleActionActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions' && !isCaptured}
|
||||||
emptyMessage="No actions available"
|
emptyMessage="No actions available"
|
||||||
renderItem={renderActionItem}
|
renderItem={renderActionItem}
|
||||||
/>
|
/>
|
||||||
@@ -382,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
|
|
||||||
{/* Show template description when templates panel is focused */}
|
{/* 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">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
@@ -461,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<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>
|
</Text>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
let isCurrent = true;
|
let isCurrent = true;
|
||||||
|
|
||||||
appService.engine.getOwnCommits(selectedInvitation.data)
|
appService.engine.findOwnCommits(selectedInvitation.data.invitationIdentifier)
|
||||||
.then((ownCommits) => {
|
.then((ownCommits) => {
|
||||||
if (!isCurrent) return;
|
if (!isCurrent) return;
|
||||||
|
|
||||||
@@ -723,10 +723,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
|
|
||||||
{/* Output description */}
|
{/* Output description */}
|
||||||
{outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
|
{outputTemplate?.description && ' - ' + compileCashAssemblyString({
|
||||||
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
cashAssemblyText: outputTemplate?.description,
|
||||||
return acc;
|
variables: variables.reduce((acc, variable) => {
|
||||||
}, {} as Record<string, XOInvitationVariableValue>))}
|
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, XOInvitationVariableValue>)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Output value */}
|
{/* Output value */}
|
||||||
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||||
|
|||||||
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export const isInvitationRequirementsComplete = async (
|
|||||||
invitation: Invitation,
|
invitation: Invitation,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
return !hasMissingRequirements(missingRequirements);
|
return !hasMissingRequirements(missingRequirements.templateRequirements);
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Move to engine in templates.ts
|
// TODO: Move to engine in templates.ts
|
||||||
|
|||||||
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
|||||||
private targetDenominatorUnitCode: string = 'BCH';
|
private targetDenominatorUnitCode: string = 'BCH';
|
||||||
private unsubscribeFromSettings: OffCallback | null = null;
|
private unsubscribeFromSettings: OffCallback | null = null;
|
||||||
|
|
||||||
private constructor(client: OracleClient, settings: SettingsService) {
|
public constructor(client: OracleClient, settings: SettingsService) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { XOInvitation } from "@xo-cash/types";
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
import { EventEmitter } from "./event-emitter.js";
|
import { EventEmitter } from "./event-emitter.js";
|
||||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
// import { SSESession, type SSEvent } from "./sse-client.js";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
|
import { SSESession, type SSEvent } from "@xo-cash/utils";
|
||||||
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
|
||||||
export type SyncServerEventMap = {
|
export type SyncServerEventMap = {
|
||||||
connected: void;
|
connected: void;
|
||||||
@@ -38,7 +39,6 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Create our event bubblers
|
// Create our event bubblers
|
||||||
onMessage: (event: SSEvent) => this.emit("message", event),
|
|
||||||
onError: (error: unknown) =>
|
onError: (error: unknown) =>
|
||||||
this.emit(
|
this.emit(
|
||||||
"error",
|
"error",
|
||||||
@@ -48,6 +48,8 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
onConnected: () => this.emit("connected", undefined),
|
onConnected: () => this.emit("connected", undefined),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.sse.on("message", (event: SSEvent) => this.emit("message", event));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +65,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
// Disconnect from the SSE Session
|
// Disconnect from the SSE Session
|
||||||
this.sse.close();
|
await this.sse.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,9 +83,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = decodeExtendedJson(await response.text()) as
|
const invitation = deserializeInvitation(await response.text());
|
||||||
| XOInvitation
|
|
||||||
| undefined;
|
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
// Send a POST request to the sync server
|
// Send a POST request to the sync server
|
||||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: encodeExtendedJson(invitation),
|
body: serializeInvitation(invitation),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -109,7 +109,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
|
|
||||||
// Read the returned JSON
|
// Read the returned JSON
|
||||||
// TODO: This should use zod to verify the response
|
// TODO: This should use zod to verify the response
|
||||||
const data = decodeExtendedJson(await response.text()) as XOInvitation;
|
const data = deserializeInvitation(await response.text());
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
58
src/utils/utxo-metadata.ts
Normal file
58
src/utils/utxo-metadata.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Engine } from "@xo-cash/engine";
|
||||||
|
import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template and output identifiers resolved from script hash storage.
|
||||||
|
*/
|
||||||
|
export type UnspentOutputMetadata = {
|
||||||
|
templateIdentifier?: string;
|
||||||
|
outputIdentifier?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a lookup map from script hash to its stored metadata.
|
||||||
|
*/
|
||||||
|
export const buildScriptHashDataMap = async (
|
||||||
|
engine: Engine,
|
||||||
|
): Promise<Map<string, ScriptHashData>> => {
|
||||||
|
const scriptHashes = await engine.listScriptHashes();
|
||||||
|
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
||||||
|
|
||||||
|
for (const scriptHashRow of scriptHashes) {
|
||||||
|
scriptHashDataByScriptHash.set(scriptHashRow.scriptHash, scriptHashRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scriptHashDataByScriptHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves template/output metadata for a single UTXO via its script hash.
|
||||||
|
*/
|
||||||
|
export const getUnspentOutputMetadata = (
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
scriptHashDataByScriptHash: Map<string, ScriptHashData>,
|
||||||
|
): UnspentOutputMetadata => {
|
||||||
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
|
|
||||||
|
if (scriptRow === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateIdentifier: scriptRow.templateIdentifier,
|
||||||
|
outputIdentifier: scriptRow.outputIdentifier,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a UTXO enriched with template/output metadata from script hash storage.
|
||||||
|
*/
|
||||||
|
export const enrichUnspentOutput = (
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
scriptHashDataByScriptHash: Map<string, ScriptHashData>,
|
||||||
|
): UnspentOutputWithMetadata => ({
|
||||||
|
...utxo,
|
||||||
|
...getUnspentOutputMetadata(utxo, scriptHashDataByScriptHash),
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -98,6 +98,13 @@ const testCases: TestCase[] = [
|
|||||||
shouldThrow: false,
|
shouldThrow: false,
|
||||||
expectedData: {},
|
expectedData: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "export returns raw template json to stdout",
|
||||||
|
inputs: ["export", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }],
|
||||||
|
},
|
||||||
// Error cases - subcommand
|
// Error cases - subcommand
|
||||||
{
|
{
|
||||||
name: "throws when no subcommand provided",
|
name: "throws when no subcommand provided",
|
||||||
@@ -124,6 +131,18 @@ const testCases: TestCase[] = [
|
|||||||
shouldThrow: true,
|
shouldThrow: true,
|
||||||
expectedEvent: "template.import.file_not_found",
|
expectedEvent: "template.import.file_not_found",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "throws when export called without template identifier",
|
||||||
|
inputs: ["export"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.export.identifier_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when export called with unknown template",
|
||||||
|
inputs: ["export", "unknown-template"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.export.not_found",
|
||||||
|
},
|
||||||
// Error cases - list category
|
// Error cases - list category
|
||||||
{
|
{
|
||||||
name: "throws when list category called without template identifier",
|
name: "throws when list category called without template identifier",
|
||||||
@@ -263,4 +282,42 @@ describe("template command", () => {
|
|||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("export prints exact engine template JSON to stdout", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier);
|
||||||
|
expect(expectedTemplate).toBeDefined();
|
||||||
|
|
||||||
|
await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["export", p2pkhTemplateIdentifier],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capture.out[0]).toBe(JSON.stringify(expectedTemplate));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export writes exact engine template JSON to file", async () => {
|
||||||
|
const outputFile = "exported-template.json";
|
||||||
|
const outputPath = path.join(tempDir, outputFile);
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier);
|
||||||
|
expect(expectedTemplate).toBeDefined();
|
||||||
|
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(tempDir);
|
||||||
|
try {
|
||||||
|
const result = await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["export", p2pkhTemplateIdentifier],
|
||||||
|
{ output: outputFile },
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportedTemplate = readFileSync(outputPath, "utf8");
|
||||||
|
expect(exportedTemplate).toBe(JSON.stringify(expectedTemplate));
|
||||||
|
expect(result.outputFile).toBe(path.resolve(process.cwd(), outputFile));
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
@@ -19,8 +20,7 @@ import {
|
|||||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
const TEST_SEED =
|
const TEST_SEED =
|
||||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
|
|
||||||
describe("mnemonic utilities", () => {
|
describe("mnemonic utilities", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ describe("mnemonic utilities", () => {
|
|||||||
test("creates a mnemonic file with auto-generated name", () => {
|
test("creates a mnemonic file with auto-generated name", () => {
|
||||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||||
|
|
||||||
expect(filename).toMatch(/^mnemonic-page$/);
|
expect(filename).toMatch(/^mnemonic-oven$/);
|
||||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +110,13 @@ describe("mnemonic utilities", () => {
|
|||||||
"/nonexistent",
|
"/nonexistent",
|
||||||
"mnemonic-relative",
|
"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 {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
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 { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -16,9 +19,10 @@ import { InMemoryStorage } from "../../../src/services/storage";
|
|||||||
import { MockElectrumService } from "./electrum-service";
|
import { MockElectrumService } from "./electrum-service";
|
||||||
import { MockRatesService } from "./rates-service";
|
import { MockRatesService } from "./rates-service";
|
||||||
import { RatesService } from "../../../src/services/rates";
|
import { RatesService } from "../../../src/services/rates";
|
||||||
|
import { SettingsService } from "../../../src/services/settings";
|
||||||
|
|
||||||
export const DEFAULT_SEED =
|
export const DEFAULT_SEED =
|
||||||
"page pencil stock planet limb cluster assault speak off joke private pioneer";
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a fake resource (UTXO) in tests.
|
* Options for creating a fake resource (UTXO) in tests.
|
||||||
@@ -67,8 +71,6 @@ export const addFakeResource = async (
|
|||||||
status: UnspentOutputStatus.CONFIRMED,
|
status: UnspentOutputStatus.CONFIRMED,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
privacy: false,
|
privacy: false,
|
||||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
|
||||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
|
||||||
outpointIndex: options.outpointIndex ?? 0,
|
outpointIndex: options.outpointIndex ?? 0,
|
||||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||||
@@ -143,10 +145,7 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
|
|
||||||
// Create the in-memory blockchain provider.
|
// Create the in-memory blockchain provider.
|
||||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||||
await blockchainProvider.initialize({
|
await blockchainProvider.initialize();
|
||||||
applicationIdentifier: "xo-cli-tests",
|
|
||||||
electrumOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the blockchain monitor instance.
|
// Create the blockchain monitor instance.
|
||||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||||
@@ -160,10 +159,13 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createMockAppService = async (engine: Engine) => {
|
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 storage = await InMemoryStorage.create();
|
||||||
|
|
||||||
const mockRates = new MockRatesService();
|
const mockRates = new MockRatesService();
|
||||||
const rates = new RatesService(mockRates);
|
const rates = new RatesService(mockRates, settings);
|
||||||
|
|
||||||
const mockElectrum = new MockElectrumService();
|
const mockElectrum = new MockElectrumService();
|
||||||
|
|
||||||
@@ -176,5 +178,5 @@ export const createMockAppService = async (engine: Engine) => {
|
|||||||
invitationStoragePath: "test-invitations.db",
|
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 { 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 { homedir, tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -93,7 +93,13 @@ describe("paths utilities", () => {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
||||||
const resolved = resolveMnemonicFilePath("mnemonic-cwd-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 {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
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