naracli 1.0.32 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,50 +1,155 @@
1
1
  /**
2
- * Agent config utilities - read/write ~/.config/nara/agent.json
2
+ * Agent config utilities
3
+ *
4
+ * Global config: ~/.config/nara/config.json — rpc_url, wallet
5
+ * Network config: ~/.config/nara/agent-{network}.json — agent_ids, zk_ids
6
+ *
7
+ * {network} is derived from the effective RPC URL:
8
+ * https://mainnet-api.nara.build/ → mainnet-api-nara-build
9
+ * https://devnet-api.nara.build/ → devnet-api-nara-build
10
+ * http://127.0.0.1:8899/ → 127-0-0-1-8899
3
11
  */
4
12
 
5
- import { join, dirname } from "node:path";
13
+ import { join } from "node:path";
6
14
  import { homedir } from "node:os";
7
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
16
+ import { DEFAULT_RPC_URL } from "nara-sdk";
8
17
 
9
- const AGENT_CONFIG_PATH = join(homedir(), ".config", "nara", "agent.json");
18
+ const CONFIG_DIR = join(homedir(), ".config", "nara");
19
+ const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.json");
10
20
 
11
- export interface AgentConfig {
12
- agent_ids: string[];
13
- zk_ids: string[];
21
+ // ─── URL → filename ─────────────────────────────────────────────
22
+
23
+ export function rpcUrlToNetworkName(url: string): string {
24
+ let name = url.replace(/^https?:\/\//, "");
25
+ name = name.replace(/\/+$/, "");
26
+ name = name.replace(/[^a-zA-Z0-9-]/g, "-");
27
+ name = name.replace(/-+/g, "-");
28
+ name = name.replace(/^-|-$/g, "");
29
+ return name;
30
+ }
31
+
32
+ function networkConfigPath(rpcUrl: string): string {
33
+ return join(CONFIG_DIR, `agent-${rpcUrlToNetworkName(rpcUrl)}.json`);
34
+ }
35
+
36
+ // ─── Global config (rpc_url, wallet) ─────────────────────────────
37
+
38
+ export interface GlobalConfig {
14
39
  rpc_url?: string;
15
40
  wallet?: string;
16
41
  }
17
42
 
18
- const DEFAULT_CONFIG: AgentConfig = { agent_ids: [], zk_ids: [] };
43
+ export function loadGlobalConfig(): GlobalConfig {
44
+ try {
45
+ const raw = readFileSync(GLOBAL_CONFIG_PATH, "utf-8");
46
+ const parsed = JSON.parse(raw);
47
+ return {
48
+ rpc_url: parsed.rpc_url ?? undefined,
49
+ wallet: parsed.wallet ?? undefined,
50
+ };
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ export function saveGlobalConfig(config: GlobalConfig): void {
57
+ mkdirSync(CONFIG_DIR, { recursive: true });
58
+ writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
59
+ }
60
+
61
+ /**
62
+ * Get the effective RPC URL (without CLI flag context).
63
+ * For use when no CLI flag is available.
64
+ */
65
+ export function getConfiguredRpcUrl(): string {
66
+ const global = loadGlobalConfig();
67
+ return global.rpc_url || DEFAULT_RPC_URL;
68
+ }
69
+
70
+ // ─── Network config (agent_ids, zk_ids) ──────────────────────────
71
+
72
+ export interface NetworkConfig {
73
+ agent_ids: string[];
74
+ zk_ids: string[];
75
+ }
76
+
77
+ const DEFAULT_NETWORK_CONFIG: NetworkConfig = { agent_ids: [], zk_ids: [] };
19
78
 
20
- export function loadAgentConfig(): AgentConfig {
79
+ /**
80
+ * Load network-specific config.
81
+ * @param rpcUrl - effective RPC URL (determines which file to load)
82
+ */
83
+ export function loadNetworkConfig(rpcUrl?: string): NetworkConfig {
84
+ const url = rpcUrl || getConfiguredRpcUrl();
85
+ const path = networkConfigPath(url);
21
86
  try {
22
- const raw = readFileSync(AGENT_CONFIG_PATH, "utf-8");
87
+ const raw = readFileSync(path, "utf-8");
23
88
  const parsed = JSON.parse(raw);
24
89
  return {
25
90
  agent_ids: Array.isArray(parsed.agent_ids) ? parsed.agent_ids : [],
26
91
  zk_ids: Array.isArray(parsed.zk_ids) ? parsed.zk_ids : [],
27
- rpc_url: parsed.rpc_url ?? undefined,
28
- wallet: parsed.wallet ?? undefined,
29
92
  };
30
93
  } catch {
31
- return { ...DEFAULT_CONFIG };
94
+ return { ...DEFAULT_NETWORK_CONFIG };
32
95
  }
33
96
  }
34
97
 
35
- export function saveAgentConfig(config: AgentConfig): void {
36
- mkdirSync(dirname(AGENT_CONFIG_PATH), { recursive: true });
37
- writeFileSync(AGENT_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
98
+ /**
99
+ * Save network-specific config.
100
+ */
101
+ export function saveNetworkConfig(config: NetworkConfig, rpcUrl?: string): void {
102
+ const url = rpcUrl || getConfiguredRpcUrl();
103
+ const path = networkConfigPath(url);
104
+ mkdirSync(CONFIG_DIR, { recursive: true });
105
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
38
106
  }
39
107
 
40
- export function addAgentId(id: string): void {
41
- const config = loadAgentConfig();
108
+ // ─── Convenience helpers ─────────────────────────────────────────
109
+
110
+ export function addAgentId(id: string, rpcUrl?: string): void {
111
+ const config = loadNetworkConfig(rpcUrl);
42
112
  config.agent_ids = [id, ...config.agent_ids.filter((x) => x !== id)];
43
- saveAgentConfig(config);
113
+ saveNetworkConfig(config, rpcUrl);
44
114
  }
45
115
 
46
- export function addZkId(name: string): void {
47
- const config = loadAgentConfig();
116
+ export function addZkId(name: string, rpcUrl?: string): void {
117
+ const config = loadNetworkConfig(rpcUrl);
48
118
  config.zk_ids = [name, ...config.zk_ids.filter((x) => x !== name)];
49
- saveAgentConfig(config);
119
+ saveNetworkConfig(config, rpcUrl);
120
+ }
121
+
122
+ // ─── Migration: import old agent.json if network config doesn't exist ──
123
+
124
+ const LEGACY_CONFIG_PATH = join(CONFIG_DIR, "agent.json");
125
+
126
+ export function migrateIfNeeded(rpcUrl?: string): void {
127
+ const url = rpcUrl || getConfiguredRpcUrl();
128
+ const path = networkConfigPath(url);
129
+ if (existsSync(path)) return;
130
+ if (!existsSync(LEGACY_CONFIG_PATH)) return;
131
+
132
+ try {
133
+ const raw = readFileSync(LEGACY_CONFIG_PATH, "utf-8");
134
+ const parsed = JSON.parse(raw);
135
+
136
+ // Migrate network-specific fields
137
+ const networkConfig: NetworkConfig = {
138
+ agent_ids: Array.isArray(parsed.agent_ids) ? parsed.agent_ids : [],
139
+ zk_ids: Array.isArray(parsed.zk_ids) ? parsed.zk_ids : [],
140
+ };
141
+ saveNetworkConfig(networkConfig, url);
142
+
143
+ // Migrate global fields if config.json doesn't exist
144
+ if (!existsSync(GLOBAL_CONFIG_PATH)) {
145
+ const globalConfig: GlobalConfig = {};
146
+ if (parsed.rpc_url) globalConfig.rpc_url = parsed.rpc_url;
147
+ if (parsed.wallet) globalConfig.wallet = parsed.wallet;
148
+ if (Object.keys(globalConfig).length > 0) {
149
+ saveGlobalConfig(globalConfig);
150
+ }
151
+ }
152
+ } catch {
153
+ // Ignore migration errors
154
+ }
50
155
  }
@@ -6,7 +6,7 @@ import { Keypair } from "@solana/web3.js";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { DEFAULT_RPC_URL } from "nara-sdk";
9
- import { loadAgentConfig } from "./agent-config";
9
+ import { loadGlobalConfig, migrateIfNeeded } from "./agent-config";
10
10
 
11
11
  const DEFAULT_WALLET_PATH = join(homedir(), ".config", "nara", "id.json");
12
12
 
@@ -22,13 +22,13 @@ function resolvePath(p: string): string {
22
22
  *
23
23
  * Priority:
24
24
  * 1. CLI flag (walletPath parameter)
25
- * 2. Config file (~/.config/nara/agent.json wallet field)
25
+ * 2. Global config (~/.config/nara/config.json wallet field)
26
26
  * 3. Default path (~/.config/nara/id.json)
27
27
  */
28
28
  export async function loadWallet(walletPath?: string): Promise<Keypair> {
29
29
  let path = walletPath;
30
30
  if (!path) {
31
- const config = loadAgentConfig();
31
+ const config = loadGlobalConfig();
32
32
  path = config.wallet ? resolvePath(config.wallet) : DEFAULT_WALLET_PATH;
33
33
  } else {
34
34
  path = resolvePath(path);
@@ -64,11 +64,13 @@ export async function loadWallet(walletPath?: string): Promise<Keypair> {
64
64
  *
65
65
  * Priority:
66
66
  * 1. CLI flag (rpcUrl parameter)
67
- * 2. Config file (~/.config/nara/agent.json rpc_url field)
67
+ * 2. Global config (~/.config/nara/config.json rpc_url field)
68
68
  * 3. Default (from SDK constants)
69
+ *
70
+ * Also triggers migration from legacy agent.json if needed.
69
71
  */
70
72
  export function getRpcUrl(rpcUrl?: string): string {
71
- if (rpcUrl) return rpcUrl;
72
- const config = loadAgentConfig();
73
- return config.rpc_url || DEFAULT_RPC_URL;
73
+ const effective = rpcUrl || loadGlobalConfig().rpc_url || DEFAULT_RPC_URL;
74
+ migrateIfNeeded(effective);
75
+ return effective;
74
76
  }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Tests for `agent` CLI commands
3
+ *
4
+ * - Help / validation tests run without wallet or chain
5
+ * - On-chain tests require wallet + NARA balance
6
+ *
7
+ * Run: npm run test:agent-cli
8
+ */
9
+
10
+ import { describe, it } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { runCli, hasWallet } from "./helpers.js";
13
+
14
+ // ─── Help output ──────────────────────────────────────────────────
15
+
16
+ describe("agent --help", () => {
17
+ it("shows all subcommands", async () => {
18
+ const { stdout, exitCode } = await runCli(["agent", "--help"]);
19
+ assert.equal(exitCode, 0);
20
+ for (const cmd of [
21
+ "register", "get", "set-bio", "set-metadata",
22
+ "upload-memory", "memory", "transfer", "close-buffer",
23
+ "delete", "set-referral", "log",
24
+ ]) {
25
+ assert.ok(stdout.includes(cmd), `missing subcommand: ${cmd}`);
26
+ }
27
+ });
28
+
29
+ it("agent register --help shows --referral option", async () => {
30
+ const { stdout, exitCode } = await runCli(["agent", "register", "--help"]);
31
+ assert.equal(exitCode, 0);
32
+ assert.ok(stdout.includes("--referral"));
33
+ assert.ok(stdout.includes("<agent-id>"));
34
+ });
35
+
36
+ it("agent set-referral --help shows both args", async () => {
37
+ const { stdout, exitCode } = await runCli(["agent", "set-referral", "--help"]);
38
+ assert.equal(exitCode, 0);
39
+ assert.ok(stdout.includes("<agent-id>"));
40
+ assert.ok(stdout.includes("<referral-agent-id>"));
41
+ });
42
+
43
+ it("agent log --help shows --model and --referral", async () => {
44
+ const { stdout, exitCode } = await runCli(["agent", "log", "--help"]);
45
+ assert.equal(exitCode, 0);
46
+ assert.ok(stdout.includes("--model"));
47
+ assert.ok(stdout.includes("--referral"));
48
+ assert.ok(stdout.includes("<agent-id>"));
49
+ assert.ok(stdout.includes("<activity>"));
50
+ assert.ok(stdout.includes("<log>"));
51
+ });
52
+
53
+ it("agent get --help shows <agent-id>", async () => {
54
+ const { stdout, exitCode } = await runCli(["agent", "get", "--help"]);
55
+ assert.equal(exitCode, 0);
56
+ assert.ok(stdout.includes("<agent-id>"));
57
+ });
58
+
59
+ it("agent set-bio --help shows <bio>", async () => {
60
+ const { stdout, exitCode } = await runCli(["agent", "set-bio", "--help"]);
61
+ assert.equal(exitCode, 0);
62
+ assert.ok(stdout.includes("<bio>"));
63
+ });
64
+
65
+ it("agent set-metadata --help shows <json>", async () => {
66
+ const { stdout, exitCode } = await runCli(["agent", "set-metadata", "--help"]);
67
+ assert.equal(exitCode, 0);
68
+ assert.ok(stdout.includes("<json>"));
69
+ });
70
+
71
+ it("agent upload-memory --help shows <file>", async () => {
72
+ const { stdout, exitCode } = await runCli(["agent", "upload-memory", "--help"]);
73
+ assert.equal(exitCode, 0);
74
+ assert.ok(stdout.includes("<file>"));
75
+ });
76
+
77
+ it("agent transfer --help shows <new-authority>", async () => {
78
+ const { stdout, exitCode } = await runCli(["agent", "transfer", "--help"]);
79
+ assert.equal(exitCode, 0);
80
+ assert.ok(stdout.includes("<new-authority>"));
81
+ });
82
+
83
+ it("agent delete --help shows <agent-id>", async () => {
84
+ const { stdout, exitCode } = await runCli(["agent", "delete", "--help"]);
85
+ assert.equal(exitCode, 0);
86
+ assert.ok(stdout.includes("<agent-id>"));
87
+ });
88
+ });
89
+
90
+ // ─── Argument validation (no chain needed) ────────────────────────
91
+
92
+ describe("agent argument errors", () => {
93
+ it("agent get with no args exits non-zero", async () => {
94
+ const { exitCode } = await runCli(["agent", "get"]);
95
+ assert.notEqual(exitCode, 0);
96
+ });
97
+
98
+ it("agent register with no args exits non-zero", async () => {
99
+ const { exitCode } = await runCli(["agent", "register"]);
100
+ assert.notEqual(exitCode, 0);
101
+ });
102
+
103
+ it("agent set-referral with no args exits non-zero", async () => {
104
+ const { exitCode } = await runCli(["agent", "set-referral"]);
105
+ assert.notEqual(exitCode, 0);
106
+ });
107
+
108
+ it("agent set-referral with only one arg exits non-zero", async () => {
109
+ const { exitCode } = await runCli(["agent", "set-referral", "my-agent"]);
110
+ assert.notEqual(exitCode, 0);
111
+ });
112
+
113
+ it("agent log with missing args exits non-zero", async () => {
114
+ const { exitCode } = await runCli(["agent", "log"]);
115
+ assert.notEqual(exitCode, 0);
116
+ });
117
+
118
+ it("agent log with only one arg exits non-zero", async () => {
119
+ const { exitCode } = await runCli(["agent", "log", "my-agent"]);
120
+ assert.notEqual(exitCode, 0);
121
+ });
122
+ });
123
+
124
+ // ─── Name validation (lowercase) ─────────────────────────────────
125
+
126
+ describe("agent name validation", () => {
127
+ it("rejects uppercase agent ID", async () => {
128
+ if (!hasWallet) return;
129
+ const { exitCode, stderr } = await runCli(["agent", "register", "MyAgent"]);
130
+ assert.equal(exitCode, 1);
131
+ assert.ok(stderr.includes("lowercase"), `stderr: ${stderr}`);
132
+ });
133
+
134
+ it("rejects agent ID starting with number", async () => {
135
+ if (!hasWallet) return;
136
+ const { exitCode, stderr } = await runCli(["agent", "register", "123agent"]);
137
+ assert.equal(exitCode, 1);
138
+ assert.ok(stderr.includes("lowercase"), `stderr: ${stderr}`);
139
+ });
140
+
141
+ it("rejects agent ID starting with hyphen", async () => {
142
+ if (!hasWallet) return;
143
+ const { exitCode, stderr } = await runCli(["agent", "register", "-my-agent"]);
144
+ // Commander may interpret -m as a flag, so check for either parse error or validation error
145
+ assert.notEqual(exitCode, 0);
146
+ });
147
+
148
+ it("rejects agent ID with special characters", async () => {
149
+ if (!hasWallet) return;
150
+ const { exitCode, stderr } = await runCli(["agent", "register", "my_agent"]);
151
+ assert.equal(exitCode, 1);
152
+ assert.ok(stderr.includes("lowercase"), `stderr: ${stderr}`);
153
+ });
154
+
155
+ it("rejects agent ID with spaces", async () => {
156
+ if (!hasWallet) return;
157
+ const { exitCode, stderr } = await runCli(["agent", "register", "my agent"]);
158
+ // Commander may treat "agent" as a separate arg
159
+ assert.notEqual(exitCode, 0);
160
+ });
161
+ });
162
+
163
+ // ─── Read-only chain queries ──────────────────────────────────────
164
+
165
+ describe("agent get (read-only)", () => {
166
+ it("returns error for non-existent agent", async () => {
167
+ const { exitCode, stderr } = await runCli(["agent", "get", "definitely-does-not-exist-xyz999"]);
168
+ assert.equal(exitCode, 1);
169
+ assert.ok(stderr.length > 0);
170
+ });
171
+ });
172
+
173
+ // ─── Metadata validation ─────────────────────────────────────────
174
+
175
+ describe("agent set-metadata validation", () => {
176
+ it("rejects invalid JSON", async () => {
177
+ if (!hasWallet) return;
178
+ const { exitCode, stderr } = await runCli(["agent", "set-metadata", "any-agent", "not-valid-json"]);
179
+ assert.equal(exitCode, 1);
180
+ assert.ok(stderr.includes("Invalid JSON"), `stderr: ${stderr}`);
181
+ });
182
+ });
183
+
184
+ // ─── Upload validation ───────────────────────────────────────────
185
+
186
+ describe("agent upload-memory validation", () => {
187
+ it("rejects non-existent file", async () => {
188
+ if (!hasWallet) return;
189
+ const { exitCode, stderr } = await runCli(["agent", "upload-memory", "any-agent", "/tmp/__no_such_file__.bin"]);
190
+ assert.equal(exitCode, 1);
191
+ assert.ok(stderr.includes("Failed to read file"), `stderr: ${stderr}`);
192
+ });
193
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Tests for `config` CLI commands and agent-config utilities
3
+ *
4
+ * - Config get/set/reset via CLI
5
+ * - rpcUrlToNetworkName conversion
6
+ * - Global and network config load/save
7
+ *
8
+ * Run: npm run test:config
9
+ */
10
+
11
+ import { describe, it, before, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { homedir } from "node:os";
16
+ import { runCli } from "./helpers.js";
17
+ import { rpcUrlToNetworkName } from "../cli/utils/agent-config.js";
18
+
19
+ const CONFIG_DIR = join(homedir(), ".config", "nara");
20
+ const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.json");
21
+
22
+ // Backup and restore global config around tests
23
+ let originalConfig: string | null = null;
24
+
25
+ before(() => {
26
+ try {
27
+ originalConfig = readFileSync(GLOBAL_CONFIG_PATH, "utf-8");
28
+ } catch {
29
+ originalConfig = null;
30
+ }
31
+ });
32
+
33
+ after(() => {
34
+ if (originalConfig !== null) {
35
+ writeFileSync(GLOBAL_CONFIG_PATH, originalConfig);
36
+ } else if (existsSync(GLOBAL_CONFIG_PATH)) {
37
+ // Restore to no config (delete if it didn't exist before)
38
+ unlinkSync(GLOBAL_CONFIG_PATH);
39
+ }
40
+ });
41
+
42
+ // ─── rpcUrlToNetworkName ──────────────────────────────────────────
43
+
44
+ describe("rpcUrlToNetworkName", () => {
45
+ it("converts mainnet URL", () => {
46
+ assert.equal(rpcUrlToNetworkName("https://mainnet-api.nara.build/"), "mainnet-api-nara-build");
47
+ });
48
+
49
+ it("converts devnet URL", () => {
50
+ assert.equal(rpcUrlToNetworkName("https://devnet-api.nara.build/"), "devnet-api-nara-build");
51
+ });
52
+
53
+ it("converts localhost URL", () => {
54
+ assert.equal(rpcUrlToNetworkName("http://127.0.0.1:8899/"), "127-0-0-1-8899");
55
+ });
56
+
57
+ it("handles URL without trailing slash", () => {
58
+ assert.equal(rpcUrlToNetworkName("https://mainnet-api.nara.build"), "mainnet-api-nara-build");
59
+ });
60
+
61
+ it("handles URL with multiple slashes", () => {
62
+ const result = rpcUrlToNetworkName("https://example.com///");
63
+ assert.ok(!result.includes("/"));
64
+ assert.ok(!result.startsWith("-"));
65
+ assert.ok(!result.endsWith("-"));
66
+ });
67
+
68
+ it("replaces dots and special chars with hyphens", () => {
69
+ assert.equal(rpcUrlToNetworkName("https://my.custom.rpc:9090/"), "my-custom-rpc-9090");
70
+ });
71
+
72
+ it("collapses multiple hyphens", () => {
73
+ const result = rpcUrlToNetworkName("http://a...b///c/");
74
+ assert.ok(!result.includes("--"));
75
+ });
76
+ });
77
+
78
+ // ─── config get ───────────────────────────────────────────────────
79
+
80
+ describe("config get", () => {
81
+ it("shows current config in text mode", async () => {
82
+ const { stdout, exitCode } = await runCli(["config", "get"]);
83
+ assert.equal(exitCode, 0);
84
+ assert.ok(stdout.includes("RPC URL:"));
85
+ assert.ok(stdout.includes("Wallet:"));
86
+ assert.ok(stdout.includes("Network:"));
87
+ });
88
+
89
+ it("--json returns structured config", async () => {
90
+ const { stdout, exitCode } = await runCli(["config", "get", "--json"]);
91
+ assert.equal(exitCode, 0);
92
+ const data = JSON.parse(stdout);
93
+ assert.ok(typeof data.rpc_url === "string");
94
+ assert.ok(typeof data.wallet === "string");
95
+ assert.ok(typeof data.network === "string");
96
+ assert.ok(Array.isArray(data.agent_ids));
97
+ assert.ok(Array.isArray(data.zk_ids));
98
+ });
99
+ });
100
+
101
+ // ─── config set ───────────────────────────────────────────────────
102
+
103
+ describe("config set", () => {
104
+ it("sets rpc-url", async () => {
105
+ const testUrl = "https://test-rpc.example.com/";
106
+ const { exitCode, stdout } = await runCli(["config", "set", "rpc-url", testUrl]);
107
+ assert.equal(exitCode, 0);
108
+
109
+ // Verify it was saved
110
+ const { stdout: getOut } = await runCli(["config", "get", "--json"]);
111
+ const data = JSON.parse(getOut);
112
+ assert.equal(data.rpc_url, testUrl);
113
+ assert.equal(data.rpc_url_custom, true);
114
+ });
115
+
116
+ it("sets wallet path", async () => {
117
+ const testPath = "/tmp/test-wallet.json";
118
+ const { exitCode } = await runCli(["config", "set", "wallet", testPath]);
119
+ assert.equal(exitCode, 0);
120
+
121
+ const { stdout } = await runCli(["config", "get", "--json"]);
122
+ const data = JSON.parse(stdout);
123
+ assert.equal(data.wallet, testPath);
124
+ assert.equal(data.wallet_custom, true);
125
+ });
126
+
127
+ it("rejects unknown config key", async () => {
128
+ const { exitCode, stderr } = await runCli(["config", "set", "unknown-key", "value"]);
129
+ assert.equal(exitCode, 1);
130
+ assert.ok(stderr.includes("Unknown config key"));
131
+ });
132
+
133
+ it("--json returns set confirmation", async () => {
134
+ const { stdout, exitCode } = await runCli(["config", "set", "rpc-url", "https://example.com/", "--json"]);
135
+ assert.equal(exitCode, 0);
136
+ const data = JSON.parse(stdout);
137
+ assert.equal(data.key, "rpc-url");
138
+ assert.equal(data.value, "https://example.com/");
139
+ });
140
+ });
141
+
142
+ // ─── config reset ─────────────────────────────────────────────────
143
+
144
+ describe("config reset", () => {
145
+ before(async () => {
146
+ // Set both values first
147
+ await runCli(["config", "set", "rpc-url", "https://test.example.com/"]);
148
+ await runCli(["config", "set", "wallet", "/tmp/test.json"]);
149
+ });
150
+
151
+ it("resets a single key", async () => {
152
+ const { exitCode } = await runCli(["config", "reset", "rpc-url"]);
153
+ assert.equal(exitCode, 0);
154
+
155
+ const { stdout } = await runCli(["config", "get", "--json"]);
156
+ const data = JSON.parse(stdout);
157
+ assert.equal(data.rpc_url_custom, false);
158
+ // wallet should still be custom
159
+ assert.equal(data.wallet_custom, true);
160
+ });
161
+
162
+ it("resets all keys", async () => {
163
+ // Set again to ensure both are custom
164
+ await runCli(["config", "set", "rpc-url", "https://test.example.com/"]);
165
+ const { exitCode } = await runCli(["config", "reset"]);
166
+ assert.equal(exitCode, 0);
167
+
168
+ const { stdout } = await runCli(["config", "get", "--json"]);
169
+ const data = JSON.parse(stdout);
170
+ assert.equal(data.rpc_url_custom, false);
171
+ assert.equal(data.wallet_custom, false);
172
+ });
173
+
174
+ it("rejects unknown reset key", async () => {
175
+ const { exitCode, stderr } = await runCli(["config", "reset", "bad-key"]);
176
+ assert.equal(exitCode, 1);
177
+ assert.ok(stderr.includes("Unknown config key"));
178
+ });
179
+
180
+ it("--json returns reset confirmation", async () => {
181
+ const { stdout, exitCode } = await runCli(["config", "reset", "rpc-url", "--json"]);
182
+ assert.equal(exitCode, 0);
183
+ const data = JSON.parse(stdout);
184
+ assert.equal(data.key, "rpc-url");
185
+ assert.equal(data.reset, true);
186
+ });
187
+ });
188
+
189
+ // ─── config --help ────────────────────────────────────────────────
190
+
191
+ describe("config --help", () => {
192
+ it("shows subcommands", async () => {
193
+ const { stdout, exitCode } = await runCli(["config", "--help"]);
194
+ assert.equal(exitCode, 0);
195
+ assert.ok(stdout.includes("get"));
196
+ assert.ok(stdout.includes("set"));
197
+ assert.ok(stdout.includes("reset"));
198
+ });
199
+ });
@@ -7,6 +7,7 @@ import { join, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { existsSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
+ import type { Connection } from "@solana/web3.js";
10
11
 
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const CLI = join(__dirname, "../../bin/nara-cli.ts");
@@ -57,3 +58,22 @@ export const hasWallet = existsSync(join(homedir(), ".config", "nara", "id.json"
57
58
  export function uniqueName(prefix: string): string {
58
59
  return `${prefix}-${Date.now().toString(36)}`;
59
60
  }
61
+
62
+ /**
63
+ * Poll for transaction confirmation (avoids WebSocket-based confirmTransaction
64
+ * which fails with TLS errors on some networks).
65
+ */
66
+ export async function pollConfirmation(
67
+ connection: Connection,
68
+ signature: string,
69
+ maxRetries = 30,
70
+ intervalMs = 1000
71
+ ): Promise<void> {
72
+ for (let i = 0; i < maxRetries; i++) {
73
+ const { value } = await connection.getSignatureStatuses([signature]);
74
+ const status = value[0]?.confirmationStatus;
75
+ if (status === "confirmed" || status === "finalized") return;
76
+ await new Promise((r) => setTimeout(r, intervalMs));
77
+ }
78
+ throw new Error(`Transaction ${signature} not confirmed after ${maxRetries}s`);
79
+ }