diff --git a/package.json b/package.json index dca1675..e3e971b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/readme.md b/readme.md index e8f884f..f122c03 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/src/cli/README.md b/src/cli/README.md index 146805f..c320455 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -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 shell’s 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 [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 `, 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/` +3. `$XO_CONFIG_DIR/mnemonics/` ```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 --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 | diff --git a/src/cli/autocomplete/completions.ts b/src/cli/autocomplete/completions.ts index 9b6b2c9..f71aa96 100644 --- a/src/cli/autocomplete/completions.ts +++ b/src/cli/autocomplete/completions.ts @@ -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 string> = { bash: generateBashCompletions, @@ -202,51 +203,74 @@ const generators: Record 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; } diff --git a/src/cli/autocomplete/scripts/bash.sh b/src/cli/autocomplete/scripts/bash.sh index 7089dae..5e7b960 100644 --- a/src/cli/autocomplete/scripts/bash.sh +++ b/src/cli/autocomplete/scripts/bash.sh @@ -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") diff --git a/src/cli/autocomplete/scripts/fish.fish b/src/cli/autocomplete/scripts/fish.fish index 5320c4f..3375671 100644 --- a/src/cli/autocomplete/scripts/fish.fish +++ b/src/cli/autocomplete/scripts/fish.fish @@ -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}} diff --git a/src/cli/autocomplete/scripts/zsh.zsh b/src/cli/autocomplete/scripts/zsh.zsh index 1853a97..0f659e7 100644 --- a/src/cli/autocomplete/scripts/zsh.zsh +++ b/src/cli/autocomplete/scripts/zsh.zsh @@ -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 diff --git a/src/services/settings.ts b/src/services/settings.ts index 19c3523..ed8fa5e 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -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 `/.wallet`. Historically it stored a raw * mnemonic reference string. This service migrates that legacy format to JSON: * `{ "default-mnemonic": "", "currency": "USD" }`. */ @@ -191,4 +191,4 @@ export class SettingsService extends EventEmitter { } return normalizedCurrency; } -} \ No newline at end of file +} diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index c83755e..29a1575 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -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([]); 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] ' : '[ ] '} Save this mnemonic - (~/.config/xo-cli/mnemonics/) + ({getMnemonicsDir()}/) {focusedSection === 'saveCheckbox' && ( diff --git a/src/utils/paths.ts b/src/utils/paths.ts index b5d25b4..549fee2 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -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/. + * Order: absolute path if it exists → path relative to cwd → config mnemonics directory/. * * @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`) * @returns Absolute path to the mnemonic file diff --git a/tests/cli/autocomplete-completions.test.ts b/tests/cli/autocomplete-completions.test.ts new file mode 100644 index 0000000..b74fe44 --- /dev/null +++ b/tests/cli/autocomplete-completions.test.ts @@ -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"); + }); +}); diff --git a/tests/cli/paths.test.ts b/tests/cli/paths.test.ts index b63351c..848a9d7 100644 --- a/tests/cli/paths.test.ts +++ b/tests/cli/paths.test.ts @@ -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");