Add custom path support for cli/tui in terminal config

This commit is contained in:
2026-06-01 11:49:23 +02:00
parent 5e9c6db412
commit b30243f674
12 changed files with 264 additions and 42 deletions

View File

@@ -12,6 +12,7 @@
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
"build": "tsc && npm run build:copy-scripts",
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
"test": "vitest --run --passWithNoTests",
"test:watch": "vitest",

View File

@@ -126,6 +126,10 @@ npm install -g .
### Install autocomplete completions (From the xo-cli directory)
These commands add `XO_CONFIG_DIR` to your shell config with a default of
`~/.config/xo-cli`. Set it to an absolute path before installing, or edit the
generated assignment, to use a different wallet-state directory.
#### Install for bash
```bash
npm run autocomplete:install:bash

View File

@@ -9,13 +9,13 @@ There are two global commands after install:
## Global config directory
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root.
| Path | Purpose |
| ----------------------------- | ----------------------------------------------------------------------- |
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
| `~/.config/xo-cli/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
| Path | Purpose |
| -------------------------- | ----------------------------------------------------------------------- |
| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) |
| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
**Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
@@ -39,21 +39,24 @@ npx tsx src/cli/index.ts <command> [options]
npx tsx src/index.ts # TUI
```
### Environment variables (TUI / `xo-tui`)
### Environment variables
| Variable | Default |
| ------------------------- | ----------------------------------------- |
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
| `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `~/.config/xo-cli/data` |
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
| `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory.
## Getting Started
### Wallet Setup
```bash
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
# Generate a new mnemonic (saved under $XO_CONFIG_DIR/mnemonics/)
xo-cli mnemonic create
# Import an existing mnemonic seed phrase
@@ -68,7 +71,7 @@ xo-cli mnemonic list
### Wallet Persistence
The first time you pass `-m <name>`, that reference is saved as
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
`default-mnemonic` in `$XO_CONFIG_DIR/.wallet`. Later runs can omit `-m`.
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
@@ -76,7 +79,7 @@ Mnemonic resolution order:
1. Absolute path, if the file exists
2. Path relative to the current working directory
3. `~/.config/xo-cli/mnemonics/<basename>`
3. `$XO_CONFIG_DIR/mnemonics/<basename>`
```bash
xo-cli resource list -m mnemonic-nuclear
@@ -93,7 +96,7 @@ xo-cli resource list
| `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`).
## Commands
@@ -201,9 +204,11 @@ eval "$(xo-cli completions zsh)"
xo-cli completions fish | source
```
`xo-cli completions <shell> --install` adds a default `XO_CONFIG_DIR` assignment to the shell startup file if one is not already present. Mnemonic aliases are completed directly from `$XO_CONFIG_DIR/mnemonics/`; database-backed suggestions still use `xo-complete`.
## File Conventions
| Location | Purpose |
| ------------------- | ------------------------------------------ |
| `~/.config/xo-cli/` | Global wallet state |
| `./` (cwd) | Templates, invitation JSON, explicit paths |
| Location | Purpose |
| ---------------- | ------------------------------------------ |
| `$XO_CONFIG_DIR` | Global wallet state |
| `./` (cwd) | Templates, invitation JSON, explicit paths |

View File

@@ -23,6 +23,7 @@ import {
existsSync,
readFileSync,
appendFileSync,
mkdirSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -193,7 +194,7 @@ export function generateFishCompletions(binName: string): string {
return loadAndProcessTemplate("fish.fish", binName);
}
type ShellType = "bash" | "zsh" | "fish";
export type ShellType = "bash" | "zsh" | "fish";
const generators: Record<ShellType, (binName: string) => string> = {
bash: generateBashCompletions,
@@ -202,51 +203,74 @@ const generators: Record<ShellType, (binName: string) => string> = {
};
/**
* Shell config file paths and eval commands for each shell type.
* Shell config file paths and startup commands for each shell type.
*/
const shellConfigs: Record<
ShellType,
{ configFile: string; evalCommand: (binName: string) => string }
{
configFile: string;
configDirCommand: string;
configDirPattern: RegExp;
evalCommand: (binName: string) => string;
}
> = {
bash: {
configFile: join(homedir(), ".bashrc"),
configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
},
zsh: {
configFile: join(homedir(), ".zshrc"),
configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
},
fish: {
configFile: join(homedir(), ".config", "fish", "config.fish"),
configDirCommand:
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m,
evalCommand: (binName) => `${binName} completions fish | source`,
},
};
/**
* Installs completions to the user's shell config file.
* Adds the eval command if not already present.
* Adds a default config directory and the eval command if not already present.
* @param shell - The shell type
* @param binName - The CLI binary name
* @returns true if installed, false if already present
*/
function installCompletions(shell: ShellType, binName: string): boolean {
const config = shellConfigs[shell];
export function installCompletions(
shell: ShellType,
binName: string,
configFile: string = shellConfigs[shell].configFile,
): boolean {
const config = { ...shellConfigs[shell], configFile };
const evalCommand = config.evalCommand(binName);
// Check if config file exists and already has the completion line
let existingContent = "";
if (existsSync(config.configFile)) {
existingContent = readFileSync(config.configFile, "utf8");
if (existingContent.includes(evalCommand)) {
return false; // Already installed
}
}
// Append the completion line
const commands: string[] = [];
if (!config.configDirPattern.test(existingContent)) {
commands.push(config.configDirCommand);
}
if (!existingContent.includes(evalCommand)) {
commands.push(evalCommand);
}
if (commands.length === 0) {
return false;
}
const newLine =
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`;
mkdirSync(dirname(config.configFile), { recursive: true });
appendFileSync(config.configFile, completionBlock);
return true;
}

View File

@@ -26,6 +26,19 @@ __xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Lists mnemonic aliases directly from the config directory without starting
# the dynamic Node helper.
__xo_complete_mnemonics() {
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
local file mnemonic
for file in "${config_dir}"/mnemonics/mnemonic-*; do
[[ -f "${file}" ]] || continue
mnemonic="${file##*/}"
[[ "${mnemonic}" == "$1"* ]] && printf '%s\n' "${mnemonic}"
done
}
# @description
# Main completion dispatcher invoked by bash's `complete -F`.
# It determines context (command/subcommand/argument position) and then mixes:
@@ -39,10 +52,10 @@ _{{FUNC_NAME}}_completions() {
_init_completion || return
# If the previous token is `-m/--mnemonic-file`, this argument expects a
# mnemonic file alias/path. Ask the helper for mnemonic suggestions.
# mnemonic file alias/path. List mnemonic aliases directly from disk.
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=$(__xo_complete mnemonics "${cur}")
mnemonics=$(__xo_complete_mnemonics "${cur}")
if [[ -n "${mnemonics}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")

View File

@@ -28,6 +28,21 @@ function __{{FUNC_NAME}}_complete_dynamic
end
end
# @description
# Lists mnemonic aliases directly from the config directory without starting
# the dynamic Node helper.
function __{{FUNC_NAME}}_complete_mnemonics
set -l config_dir "$XO_CONFIG_DIR"
if test -z "$config_dir"
set config_dir "$HOME/.config/xo-cli"
end
for file in $config_dir/mnemonics/mnemonic-*
if test -f "$file"
string replace -r '.*/' '' "$file"
end
end
end
# Global option flags available across top-level command contexts.
complete -c {{BIN_NAME}} -s h -d "Show help"
complete -c {{BIN_NAME}} -l help -d "Show help"
@@ -37,8 +52,8 @@ complete -c {{BIN_NAME}} -s o -d "Output file"
complete -c {{BIN_NAME}} -l output -d "Output file"
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
# Dynamic completion for `-m/--mnemonic-file`.
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Shell-native completion for `-m/--mnemonic-file`.
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_mnemonics)'
# Top-level command registrations inserted by template expansion.
{{TOP_LEVEL_COMMANDS}}

View File

@@ -25,6 +25,19 @@ __xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
# @description
# Lists mnemonic aliases directly from the config directory without starting
# the dynamic Node helper.
__xo_complete_mnemonics() {
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
local file mnemonic
for file in "${config_dir}"/mnemonics/mnemonic-*(N); do
[[ -f "${file}" ]] || continue
mnemonic="${file:t}"
[[ "${mnemonic}" == "$1"* ]] && print -r -- "${mnemonic}"
done
}
# @description
# Main zsh completion dispatcher registered via `compdef`.
# It resolves command context from `$words`/`$CURRENT` and serves:
@@ -38,7 +51,7 @@ _{{FUNC_NAME}}_completions() {
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
mnemonics=("${(@f)$(__xo_complete_mnemonics "${words[CURRENT]}")}")
if [[ ${#mnemonics[@]} -gt 0 ]]; then
compadd -- "${mnemonics[@]}"
return

View File

@@ -32,7 +32,7 @@ const DEFAULT_SETTINGS: SettingsData = {
/**
* Handles loading, migrating, and persisting wallet settings.
*
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
* The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/
@@ -191,4 +191,4 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
}
return normalizedCurrency;
}
}
}

View File

@@ -44,7 +44,7 @@ interface MnemonicFileEntry {
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
/**
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
* Reads mnemonic-* files from the configured mnemonics directory (same as xo-cli),
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
*/
function loadMnemonicFiles(): MnemonicFileEntry[] {
@@ -101,7 +101,7 @@ export function SeedInputScreen(): React.ReactElement {
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
/** When set, manual seed is written to the configured mnemonics directory after a successful unlock. */
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
// Focus: when saved wallets exist default to the file list, otherwise the input.
@@ -397,7 +397,7 @@ export function SeedInputScreen(): React.ReactElement {
{saveMnemonicChecked ? '[x] ' : '[ ] '}
</Text>
<Text color={colors.text}>Save this mnemonic</Text>
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
<Text color={colors.textMuted}> ({getMnemonicsDir()}/)</Text>
</Box>
{focusedSection === 'saveCheckbox' && (
<Box marginTop={0} paddingX={1}>

View File

@@ -1,5 +1,5 @@
/**
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
* Global XO CLI config layout (`XO_CONFIG_DIR` or ~/.config/xo-cli/).
* User-provided paths (templates, invitation JSON) stay relative to cwd.
*/
@@ -11,7 +11,7 @@ import { basename, isAbsolute, join, resolve } from "node:path";
* Base config directory. Created on first access.
*/
export function getConfigDir(): string {
const dir = join(homedir(), ".config", "xo-cli");
const dir = process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli");
mkdirSync(dir, { recursive: true });
return dir;
}
@@ -50,7 +50,7 @@ export function getWalletConfigPath(): string {
/**
* Resolves a mnemonic reference to an absolute path.
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
* Order: absolute path if it exists → path relative to cwd → config mnemonics directory/<basename>.
*
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
* @returns Absolute path to the mnemonic file

View File

@@ -0,0 +1,112 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
generateBashCompletions,
generateFishCompletions,
generateZshCompletions,
installCompletions,
} from "../../src/cli/autocomplete/completions";
describe("shell completions", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs) {
rmSync(tempDir, { recursive: true, force: true });
}
tempDirs.length = 0;
});
function createConfigFile(contents = ""): string {
const tempDir = mkdtempSync(join(tmpdir(), "xo-cli-completions-test-"));
tempDirs.push(tempDir);
const configFile = join(tempDir, "shellrc");
writeFileSync(configFile, contents);
return configFile;
}
test("uses shell-native mnemonic completion in bash", () => {
const completions = generateBashCompletions("xo-cli");
expect(completions).toContain(
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
);
expect(completions).toContain('__xo_complete_mnemonics "${cur}"');
expect(completions).not.toContain('__xo_complete mnemonics "${cur}"');
});
test("uses shell-native mnemonic completion in zsh", () => {
const completions = generateZshCompletions("xo-cli");
expect(completions).toContain(
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
);
expect(completions).toContain(
'__xo_complete_mnemonics "${words[CURRENT]}"',
);
expect(completions).not.toContain(
'__xo_complete mnemonics "${words[CURRENT]}"',
);
});
test("uses shell-native mnemonic completion in fish", () => {
const completions = generateFishCompletions("xo-cli");
expect(completions).toContain("set -l config_dir \"$XO_CONFIG_DIR\"");
expect(completions).toContain("(__xo_cli_complete_mnemonics)");
expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)");
});
test("installs the config default and completion loader once", () => {
const configFile = createConfigFile();
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
expect(installCompletions("bash", "xo-cli", configFile)).toBe(false);
const contents = readFileSync(configFile, "utf8");
expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2);
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength(
1,
);
});
test("adds a missing default without duplicating an existing loader", () => {
const configFile = createConfigFile('eval "$(xo-cli completions bash)"\n');
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
const contents = readFileSync(configFile, "utf8");
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength(
1,
);
expect(contents).toContain(
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
);
});
test("preserves an existing custom config directory assignment", () => {
const configFile = createConfigFile("export XO_CONFIG_DIR=/tmp/custom-xo\n");
expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true);
const contents = readFileSync(configFile, "utf8");
expect(contents).toContain("export XO_CONFIG_DIR=/tmp/custom-xo");
expect(contents).not.toContain("${XO_CONFIG_DIR:-$HOME/.config/xo-cli}");
expect(contents).toContain('eval "$(xo-cli completions zsh)"');
});
test("uses fish syntax when installing fish completions", () => {
const configFile = createConfigFile();
expect(installCompletions("fish", "xo-cli", configFile)).toBe(true);
const contents = readFileSync(configFile, "utf8");
expect(contents).toContain(
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
);
expect(contents).toContain("xo-cli completions fish | source");
});
});

View File

@@ -12,6 +12,20 @@ import {
} from "../../src/utils/paths";
describe("paths utilities", () => {
const originalConfigDir = process.env["XO_CONFIG_DIR"];
beforeEach(() => {
delete process.env["XO_CONFIG_DIR"];
});
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env["XO_CONFIG_DIR"];
} else {
process.env["XO_CONFIG_DIR"] = originalConfigDir;
}
});
describe("getConfigDir", () => {
test("returns path under ~/.config/xo-cli", () => {
const configDir = getConfigDir();
@@ -24,6 +38,26 @@ describe("paths utilities", () => {
expect(existsSync(configDir)).toBe(true);
});
test("uses XO_CONFIG_DIR when configured", () => {
const customDir = path.join(tmpdir(), `xo-cli-config-test-${Date.now()}`);
process.env["XO_CONFIG_DIR"] = customDir;
try {
expect(getConfigDir()).toBe(customDir);
expect(getMnemonicsDir()).toBe(path.join(customDir, "mnemonics"));
expect(getDataDir()).toBe(path.join(customDir, "data"));
expect(getWalletConfigPath()).toBe(path.join(customDir, ".wallet"));
} finally {
rmSync(customDir, { recursive: true, force: true });
}
});
test("uses the default when XO_CONFIG_DIR is empty", () => {
process.env["XO_CONFIG_DIR"] = "";
expect(getConfigDir()).toBe(path.join(homedir(), ".config", "xo-cli"));
});
});
describe("getMnemonicsDir", () => {
@@ -106,6 +140,7 @@ describe("paths utilities", () => {
});
test("resolves from global mnemonics dir when file exists there", () => {
process.env["XO_CONFIG_DIR"] = tempDir;
const mnemonicsDir = getMnemonicsDir();
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");