cisco-perfmon 1.6.0 → 2.0.0

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.
@@ -0,0 +1,112 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const os = require("node:os");
4
+ const { execSync } = require("node:child_process");
5
+
6
+ const SS_PLACEHOLDER_RE = /<ss:(\d+):(\w+)>/g;
7
+
8
+ function getConfigDir() {
9
+ return process.env.CISCO_PERFMON_CONFIG_DIR || path.join(os.homedir(), ".cisco-perfmon");
10
+ }
11
+
12
+ function getConfigPath() {
13
+ return path.join(getConfigDir(), "config.json");
14
+ }
15
+
16
+ function loadConfig() {
17
+ const configPath = getConfigPath();
18
+ if (!fs.existsSync(configPath)) return { activeCluster: null, clusters: {} };
19
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
20
+ }
21
+
22
+ function saveConfig(config) {
23
+ const dir = getConfigDir();
24
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), { mode: 0o600 });
26
+ }
27
+
28
+ function addCluster(name, opts) {
29
+ const config = loadConfig();
30
+ config.clusters[name] = { host: opts.host, username: opts.username, password: opts.password };
31
+ if (opts.insecure) config.clusters[name].insecure = true;
32
+ if (!config.activeCluster) config.activeCluster = name;
33
+ saveConfig(config);
34
+ }
35
+
36
+ function useCluster(name) {
37
+ const config = loadConfig();
38
+ if (!config.clusters[name]) throw new Error(`Cluster "${name}" not found. Run "cisco-perfmon config list" to see available clusters.`);
39
+ config.activeCluster = name;
40
+ saveConfig(config);
41
+ }
42
+
43
+ function removeCluster(name) {
44
+ const config = loadConfig();
45
+ if (!config.clusters[name]) throw new Error(`Cluster "${name}" not found.`);
46
+ delete config.clusters[name];
47
+ if (config.activeCluster === name) {
48
+ const remaining = Object.keys(config.clusters);
49
+ config.activeCluster = remaining.length > 0 ? remaining[0] : null;
50
+ }
51
+ saveConfig(config);
52
+ }
53
+
54
+ function getActiveCluster(clusterName) {
55
+ const config = loadConfig();
56
+ const name = clusterName || config.activeCluster;
57
+ if (!name || !config.clusters[name]) return null;
58
+ return { name, ...config.clusters[name] };
59
+ }
60
+
61
+ function listClusters() {
62
+ const config = loadConfig();
63
+ return { activeCluster: config.activeCluster, clusters: config.clusters };
64
+ }
65
+
66
+ function maskPassword(password) {
67
+ if (!password) return "";
68
+ SS_PLACEHOLDER_RE.lastIndex = 0;
69
+ if (SS_PLACEHOLDER_RE.test(password)) { SS_PLACEHOLDER_RE.lastIndex = 0; return password; }
70
+ return "*".repeat(password.length);
71
+ }
72
+
73
+ function hasSsPlaceholders(obj) {
74
+ for (const value of Object.values(obj)) {
75
+ if (typeof value === "string") {
76
+ SS_PLACEHOLDER_RE.lastIndex = 0;
77
+ if (SS_PLACEHOLDER_RE.test(value)) { SS_PLACEHOLDER_RE.lastIndex = 0; return true; }
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function resolveSsPlaceholders(obj) {
84
+ const resolved = { ...obj };
85
+ for (const [key, value] of Object.entries(resolved)) {
86
+ if (typeof value !== "string") continue;
87
+ SS_PLACEHOLDER_RE.lastIndex = 0;
88
+ resolved[key] = value.replace(SS_PLACEHOLDER_RE, (match, id, field) => {
89
+ try {
90
+ const output = execSync(`ss-cli get ${id} --format json`, { encoding: "utf-8", timeout: 10000 });
91
+ const secret = JSON.parse(output);
92
+ if (secret[field] !== undefined) return secret[field];
93
+ if (Array.isArray(secret.items)) {
94
+ const item = secret.items.find((i) => i.fieldName === field || i.slug === field);
95
+ if (item) return item.itemValue;
96
+ }
97
+ throw new Error(`Field "${field}" not found in secret ${id}`);
98
+ } catch (err) {
99
+ if (err.message.includes("ENOENT") || err.message.includes("not found")) {
100
+ throw new Error(`Config contains Secret Server references (<ss:...>) but ss-cli is not available. Install with: npm install -g @sieteunoseis/ss-cli`);
101
+ }
102
+ throw err;
103
+ }
104
+ });
105
+ }
106
+ return resolved;
107
+ }
108
+
109
+ module.exports = {
110
+ getConfigDir, getConfigPath, loadConfig, saveConfig, addCluster, useCluster, removeCluster,
111
+ getActiveCluster, listClusters, maskPassword, hasSsPlaceholders, resolveSsPlaceholders,
112
+ };
@@ -0,0 +1,36 @@
1
+ const configUtil = require("./config.js");
2
+
3
+ function resolveConfig(flags) {
4
+ const env = {
5
+ host: process.env.CUCM_HOST || process.env.CUCM_HOSTNAME || undefined,
6
+ username: process.env.CUCM_USERNAME || undefined,
7
+ password: process.env.CUCM_PASSWORD || undefined,
8
+ };
9
+
10
+ let fileConfig = {};
11
+ const cluster = configUtil.getActiveCluster(flags.cluster || undefined);
12
+ if (cluster) fileConfig = cluster;
13
+
14
+ const resolved = {
15
+ host: flags.host || env.host || fileConfig.host,
16
+ username: flags.username || env.username || fileConfig.username,
17
+ password: flags.password || env.password || fileConfig.password,
18
+ insecure: flags.insecure || fileConfig.insecure || false,
19
+ };
20
+
21
+ if (!resolved.host || !resolved.username || !resolved.password) {
22
+ throw new Error(
23
+ "No cluster configured. Set one up with:\n" +
24
+ " cisco-perfmon config add <name> --host <h> --username <u> --password <p>\n" +
25
+ " Or set environment variables: CUCM_HOST, CUCM_USERNAME, CUCM_PASSWORD"
26
+ );
27
+ }
28
+
29
+ if (configUtil.hasSsPlaceholders(resolved)) {
30
+ Object.assign(resolved, configUtil.resolveSsPlaceholders(resolved));
31
+ }
32
+
33
+ return resolved;
34
+ }
35
+
36
+ module.exports = { resolveConfig };
@@ -0,0 +1,28 @@
1
+ const formatTable = require("../formatters/table.js");
2
+ const formatJson = require("../formatters/json.js");
3
+ const formatToon = require("../formatters/toon.js");
4
+ const formatCsv = require("../formatters/csv.js");
5
+
6
+ const formatters = { table: formatTable, json: formatJson, toon: formatToon, csv: formatCsv };
7
+
8
+ async function printResult(data, format) {
9
+ const formatter = formatters[format || "table"];
10
+ if (!formatter) throw new Error(`Unknown format "${format}". Valid: table, json, toon, csv`);
11
+ const output = await Promise.resolve(formatter(data));
12
+ console.log(output);
13
+ }
14
+
15
+ function printError(err) {
16
+ const message = err.message || String(err);
17
+ process.stderr.write(`Error: ${message}\n`);
18
+ if (message.includes("Authentication failed") || (err.status === 401)) {
19
+ process.stderr.write('Hint: Run "cisco-perfmon config test" to verify your credentials.\n');
20
+ } else if (message.includes("Rate limit") || message.includes("Exceeded allowed rate") || err.status === 429) {
21
+ process.stderr.write("Hint: Wait 30 seconds and try again, or reduce polling frequency with --interval.\n");
22
+ } else if (message.includes("certificate") || message.includes("CERT")) {
23
+ process.stderr.write("Hint: Use --insecure for self-signed certificates.\n");
24
+ }
25
+ process.exitCode = 1;
26
+ }
27
+
28
+ module.exports = { printResult, printError };
package/main.js CHANGED
@@ -112,19 +112,21 @@ const XML_REMOVE_COUNTER_ENVELOPE = (sessionHandle, counters) => `<soapenv:Envel
112
112
  * @param {string} host - The host to collect data from. This is usually the IP address/FQDN of the CUCM publisher.
113
113
  * @param {string} username - The username to authenticate with. This is usually an AXL user. Can leave this blank if using JESSIONSSO cookie.
114
114
  * @param {string} password - The password to authenticate with. This is usually an AXL user. Can leave this blank if using JESSIONSSO cookie.
115
- * @param {object} options - Additional headers to add to the request. Useful for adding cookies for SSO sessions.
115
+ * @param {object} options - Options object. Supports `retries` (default 3), `retryDelay` ms (default 5000), and any additional headers (e.g. cookies for SSO).
116
116
  * @returns {object} returns constructor object.
117
117
  */
118
118
  class perfMonService {
119
119
  constructor(host, username, password, options = {}, retry = true) {
120
120
  const RATE_LIMIT_DELAYS = [30000, 60000, 120000]; // 30s, 60s, 120s exponential backoff
121
+ const maxRetries = options.retries ?? (process.env.PM_RETRY ? parseInt(process.env.PM_RETRY) : 3);
122
+ const retryDelay = options.retryDelay ?? (process.env.PM_RETRY_DELAY ? parseInt(process.env.PM_RETRY_DELAY) : 5000);
121
123
 
122
124
  this._OPTIONS = {
123
125
  retryOn: async function (attempt, error, response) {
124
126
  if (!retry) {
125
127
  return false;
126
128
  }
127
- if (attempt > (process.env.PM_RETRY ? parseInt(process.env.PM_RETRY) : 3)) {
129
+ if (attempt > maxRetries) {
128
130
  return false;
129
131
  }
130
132
 
@@ -147,7 +149,7 @@ class perfMonService {
147
149
  // retry on any network error, or 4xx or 5xx status codes
148
150
  if (error !== null || response.status >= 400) {
149
151
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
150
- await delay(process.env.PM_RETRY_DELAY ? parseInt(process.env.PM_RETRY_DELAY) : 5000);
152
+ await delay(retryDelay);
151
153
  return true;
152
154
  }
153
155
  },
@@ -204,7 +206,7 @@ class perfMonService {
204
206
  */
205
207
  async collectCounterData(host, object) {
206
208
  try {
207
- let options = this._OPTIONS;
209
+ let options = { ...this._OPTIONS, headers: { ...this._OPTIONS.headers } };
208
210
  let server = this._HOST;
209
211
  let XML = XML_COLLECT_COUNTER_ENVELOPE(escapeXml(host), escapeXml(object));
210
212
  let soapBody = Buffer.from(XML);
@@ -220,9 +222,6 @@ class perfMonService {
220
222
  };
221
223
 
222
224
  promiseResults.cookie = response.headers.get("set-cookie") ? response.headers.get("set-cookie") : "";
223
- if (promiseResults.cookie) {
224
- this.setCookie(promiseResults.cookie);
225
- }
226
225
  if (promiseResults.cookie) {
227
226
  this.setCookie(promiseResults.cookie);
228
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cisco-perfmon",
3
- "version": "1.6.0",
3
+ "version": "2.0.0",
4
4
  "description": "A library to pull Perfmon data from Cisco VOS applications via SOAP",
5
5
  "main": "main.js",
6
6
  "module": "main.mjs",
@@ -12,8 +12,14 @@
12
12
  "types": "./types/index.d.ts"
13
13
  }
14
14
  },
15
+ "bin": {
16
+ "cisco-perfmon": "./bin/cisco-perfmon.js"
17
+ },
18
+ "funding": "https://buymeacoffee.com/automatebldrs",
15
19
  "scripts": {
16
- "test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/tests.js",
20
+ "test": "node ./test/unit.js",
21
+ "test:unit": "node ./test/unit.js",
22
+ "test:integration": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/tests.js",
17
23
  "development": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=development node ./test/tests.js",
18
24
  "generate-config": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/generateConfig.js",
19
25
  "rate-limit-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=test node ./test/rateLimitTest.js"
@@ -36,7 +42,12 @@
36
42
  },
37
43
  "homepage": "https://github.com/sieteunoseis/cisco-perfmon#readme",
38
44
  "dependencies": {
45
+ "@toon-format/toon": "^2.1.0",
46
+ "cli-table3": "^0.6.5",
47
+ "commander": "^12.1.0",
48
+ "csv-stringify": "^6.5.2",
39
49
  "fetch-retry": "^6.0.0",
50
+ "update-notifier": "^7.3.1",
40
51
  "xml2js": "^0.6.0"
41
52
  },
42
53
  "devDependencies": {
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: cisco-perfmon-cli
3
+ description: Use when collecting Cisco CUCM real-time performance counters via the cisco-perfmon CLI — CPU, memory, call statistics, device counts, and continuous monitoring with sparkline visualization.
4
+ license: MIT
5
+ metadata:
6
+ author: sieteunoseis
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # cisco-perfmon CLI
11
+
12
+ CLI for collecting Cisco CUCM real-time performance counters via the PerfMon SOAP API. Supports one-shot collection, session-based polling, continuous monitoring with live sparkline visualization, and multiple output formats.
13
+
14
+ ## Setup
15
+
16
+ Configure a cluster (one-time):
17
+
18
+ ```bash
19
+ cisco-perfmon config add <name> --host <host> --username <user> --password <pass> --insecure
20
+ cisco-perfmon config test
21
+ ```
22
+
23
+ For Secret Server integration:
24
+
25
+ ```bash
26
+ cisco-perfmon config add <name> --host '<ss:ID:host>' --username '<ss:ID:username>' --password '<ss:ID:password>' --insecure
27
+ ```
28
+
29
+ Or use environment variables:
30
+
31
+ ```bash
32
+ export CUCM_HOST=<host>
33
+ export CUCM_USERNAME=<user>
34
+ export CUCM_PASSWORD=<pass>
35
+ ```
36
+
37
+ ## Commands
38
+
39
+ ### config -- Manage cluster configurations
40
+
41
+ ```bash
42
+ cisco-perfmon config add <name> --host <host> --username <user> --password <pass>
43
+ cisco-perfmon config use <name>
44
+ cisco-perfmon config list
45
+ cisco-perfmon config show
46
+ cisco-perfmon config remove <name>
47
+ cisco-perfmon config test
48
+ ```
49
+
50
+ ### list-objects -- List available perfmon counter objects
51
+
52
+ ```bash
53
+ cisco-perfmon list-objects # all objects
54
+ cisco-perfmon list-objects --search "CallManager" # filter by keyword
55
+ cisco-perfmon list-objects --format json # JSON output
56
+ ```
57
+
58
+ ### list-instances -- List instances of a perfmon object
59
+
60
+ ```bash
61
+ cisco-perfmon list-instances "Cisco CallManager"
62
+ cisco-perfmon list-instances "Process" --format json
63
+ ```
64
+
65
+ ### describe -- Get counter descriptions
66
+
67
+ ```bash
68
+ cisco-perfmon describe "Cisco CallManager"
69
+ cisco-perfmon describe "Cisco CallManager" --counter CallsActive
70
+ cisco-perfmon describe "Cisco CallManager" --counter CallsActive --instance ""
71
+ ```
72
+
73
+ ### collect -- One-shot counter data collection
74
+
75
+ ```bash
76
+ cisco-perfmon collect "Cisco CallManager" # all counters
77
+ cisco-perfmon collect "Cisco CallManager" --counter CallsActive,CallsInProgress # specific counters
78
+ cisco-perfmon collect "Cisco CallManager" --instance "" # filter by instance
79
+ cisco-perfmon collect "Processor" --format json # JSON output
80
+ cisco-perfmon collect "Memory" --format csv > memory.csv # export to CSV
81
+ ```
82
+
83
+ ### session -- Manage perfmon polling sessions
84
+
85
+ ```bash
86
+ cisco-perfmon session open # get a session handle
87
+ cisco-perfmon session add <handle> --counters '[{"host":"cucm","object":"Cisco CallManager","counter":"CallsActive"}]'
88
+ cisco-perfmon session collect <handle> # collect session data
89
+ cisco-perfmon session remove <handle> --counters '[...]'
90
+ cisco-perfmon session close <handle>
91
+ ```
92
+
93
+ ### watch -- Continuous monitoring with live sparklines
94
+
95
+ The watch command polls counters at a configurable interval and displays a live-updating table with sparkline visualizations showing value trends over the last 12 samples.
96
+
97
+ ```bash
98
+ cisco-perfmon watch "Cisco CallManager" # watch all counters
99
+ cisco-perfmon watch "Cisco CallManager" --counter CallsActive --interval 5 # specific counter, 5s interval
100
+ cisco-perfmon watch "Processor" --counter "% CPU Time" --instance "_Total" # CPU monitoring
101
+ cisco-perfmon watch "Memory" --interval 30 --duration 300 # 5-minute memory check
102
+ cisco-perfmon watch "Cisco CallManager" --format json --interval 10 # JSON output for piping
103
+ ```
104
+
105
+ The table view shows: counter name, instance, current value, sparkline trend, min, max, and average. Press Ctrl+C to stop.
106
+
107
+ ### doctor -- Configuration and connectivity health check
108
+
109
+ ```bash
110
+ cisco-perfmon doctor # run all checks
111
+ cisco-perfmon doctor --insecure # with TLS skip
112
+ ```
113
+
114
+ Checks: active cluster config, PerfMon API connectivity, counter object availability, config file permissions, audit trail size.
115
+
116
+ ## Common Workflows
117
+
118
+ ### Check system health
119
+
120
+ ```bash
121
+ cisco-perfmon doctor
122
+ cisco-perfmon collect "Cisco CallManager" --format json
123
+ ```
124
+
125
+ ### Monitor calls during testing
126
+
127
+ ```bash
128
+ cisco-perfmon watch "Cisco CallManager" --counter CallsActive,CallsInProgress,CallsAttempted --interval 5
129
+ ```
130
+
131
+ ### CPU investigation
132
+
133
+ ```bash
134
+ cisco-perfmon watch "Processor" --counter "% CPU Time" --instance "_Total" --interval 10
135
+ cisco-perfmon collect "Processor" --format json
136
+ ```
137
+
138
+ ### List what counters are available
139
+
140
+ ```bash
141
+ cisco-perfmon list-objects --search "Cisco"
142
+ cisco-perfmon list-instances "Cisco CallManager"
143
+ cisco-perfmon describe "Cisco CallManager" --counter CallsActive
144
+ ```
145
+
146
+ ### Export counter data to CSV
147
+
148
+ ```bash
149
+ cisco-perfmon collect "Cisco CallManager" --format csv > callmanager.csv
150
+ ```
151
+
152
+ ### Session-based polling for selective counters
153
+
154
+ ```bash
155
+ HANDLE=$(cisco-perfmon session open --format json | jq -r '.sessionHandle')
156
+ cisco-perfmon session add "$HANDLE" --counters '[{"host":"cucm-pub","object":"Cisco CallManager","counter":"CallsActive"}]'
157
+ cisco-perfmon session collect "$HANDLE"
158
+ cisco-perfmon session close "$HANDLE"
159
+ ```
160
+
161
+ ## Output Formats
162
+
163
+ - `--format table` (default) -- human-readable table
164
+ - `--format json` -- for scripting/parsing
165
+ - `--format toon` -- token-efficient for AI agents (recommended)
166
+ - `--format csv` -- for spreadsheets
167
+
168
+ ## Global Flags
169
+
170
+ - `--host <host>` -- override CUCM hostname
171
+ - `--username <user>` -- override CUCM username
172
+ - `--password <pass>` -- override CUCM password
173
+ - `--cluster <name>` -- use a specific named cluster
174
+ - `--insecure` -- skip TLS certificate verification (required for self-signed certs)
175
+ - `--no-audit` -- disable audit logging for this command
176
+ - `--debug` -- enable debug logging
@@ -0,0 +1,196 @@
1
+ const assert = require("assert");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ let passed = 0, failed = 0, total = 0;
7
+ function describe(name, fn) { console.log(` ${name}`); fn(); }
8
+ function it(name, fn) {
9
+ total++;
10
+ try { fn(); console.log(` \u2713 ${name}`); passed++; }
11
+ catch (e) { console.log(` \u2717 ${name}: ${e.message}`); failed++; }
12
+ }
13
+
14
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cisco-perfmon-test-config-"));
15
+ process.env.CISCO_PERFMON_CONFIG_DIR = tmpDir;
16
+
17
+ const {
18
+ loadConfig, saveConfig, addCluster, useCluster, removeCluster,
19
+ getActiveCluster, listClusters, maskPassword, hasSsPlaceholders,
20
+ getConfigDir, getConfigPath,
21
+ } = require("../../cli/utils/config.js");
22
+
23
+ describe("getConfigDir", () => {
24
+ it("returns the CISCO_PERFMON_CONFIG_DIR env value", () => {
25
+ assert.strictEqual(getConfigDir(), tmpDir);
26
+ });
27
+
28
+ it("returns path under home when env is unset", () => {
29
+ const saved = process.env.CISCO_PERFMON_CONFIG_DIR;
30
+ delete process.env.CISCO_PERFMON_CONFIG_DIR;
31
+ const dir = getConfigDir();
32
+ assert.strictEqual(dir, path.join(os.homedir(), ".cisco-perfmon"));
33
+ process.env.CISCO_PERFMON_CONFIG_DIR = saved;
34
+ });
35
+ });
36
+
37
+ describe("getConfigPath", () => {
38
+ it("returns config.json inside config dir", () => {
39
+ assert.ok(getConfigPath().endsWith("config.json"));
40
+ });
41
+ });
42
+
43
+ describe("loadConfig", () => {
44
+ it("returns defaults when no config file exists", () => {
45
+ const config = loadConfig();
46
+ assert.strictEqual(config.activeCluster, null);
47
+ assert.deepStrictEqual(config.clusters, {});
48
+ });
49
+ });
50
+
51
+ describe("saveConfig / loadConfig round-trip", () => {
52
+ it("writes and reads back the same data", () => {
53
+ const data = { activeCluster: "lab", clusters: { lab: { host: "10.0.0.1", username: "admin", password: "pass" } } };
54
+ saveConfig(data);
55
+ const loaded = loadConfig();
56
+ assert.strictEqual(loaded.activeCluster, "lab");
57
+ assert.strictEqual(loaded.clusters.lab.host, "10.0.0.1");
58
+ });
59
+
60
+ it("config file exists on disk after save", () => {
61
+ const configPath = path.join(tmpDir, "config.json");
62
+ assert.ok(fs.existsSync(configPath), "config.json should exist");
63
+ });
64
+
65
+ it("config file has restricted permissions (600)", () => {
66
+ const configPath = path.join(tmpDir, "config.json");
67
+ const stats = fs.statSync(configPath);
68
+ const mode = (stats.mode & 0o777).toString(8);
69
+ assert.strictEqual(mode, "600");
70
+ });
71
+ });
72
+
73
+ describe("addCluster", () => {
74
+ it("adds a cluster and sets it active if none active", () => {
75
+ saveConfig({ activeCluster: null, clusters: {} });
76
+ addCluster("prod", { host: "cucm.example.com", username: "admin", password: "secret" });
77
+ const config = loadConfig();
78
+ assert.strictEqual(config.activeCluster, "prod");
79
+ assert.strictEqual(config.clusters.prod.host, "cucm.example.com");
80
+ });
81
+
82
+ it("adds insecure flag when specified", () => {
83
+ addCluster("lab2", { host: "lab.local", username: "u", password: "p", insecure: true });
84
+ const config = loadConfig();
85
+ assert.strictEqual(config.clusters.lab2.insecure, true);
86
+ });
87
+
88
+ it("does not overwrite activeCluster if one already set", () => {
89
+ const config = loadConfig();
90
+ assert.strictEqual(config.activeCluster, "prod");
91
+ });
92
+ });
93
+
94
+ describe("useCluster", () => {
95
+ it("switches the active cluster", () => {
96
+ useCluster("lab2");
97
+ const config = loadConfig();
98
+ assert.strictEqual(config.activeCluster, "lab2");
99
+ });
100
+
101
+ it("throws for unknown cluster", () => {
102
+ assert.throws(() => useCluster("nonexistent"), /not found/);
103
+ });
104
+ });
105
+
106
+ describe("removeCluster", () => {
107
+ it("removes a cluster", () => {
108
+ removeCluster("lab2");
109
+ const config = loadConfig();
110
+ assert.strictEqual(config.clusters.lab2, undefined);
111
+ });
112
+
113
+ it("reassigns activeCluster when removing the active one", () => {
114
+ saveConfig({ activeCluster: "a", clusters: { a: { host: "h", username: "u", password: "p" }, b: { host: "h2", username: "u2", password: "p2" } } });
115
+ removeCluster("a");
116
+ const config = loadConfig();
117
+ assert.strictEqual(config.activeCluster, "b");
118
+ });
119
+
120
+ it("sets activeCluster to null when removing last cluster", () => {
121
+ removeCluster("b");
122
+ const config = loadConfig();
123
+ assert.strictEqual(config.activeCluster, null);
124
+ });
125
+
126
+ it("throws for unknown cluster", () => {
127
+ assert.throws(() => removeCluster("nonexistent"), /not found/);
128
+ });
129
+ });
130
+
131
+ describe("getActiveCluster", () => {
132
+ it("returns null when no clusters configured", () => {
133
+ saveConfig({ activeCluster: null, clusters: {} });
134
+ assert.strictEqual(getActiveCluster(), null);
135
+ });
136
+
137
+ it("returns the active cluster with name merged in", () => {
138
+ saveConfig({ activeCluster: "prod", clusters: { prod: { host: "h", username: "u", password: "p" } } });
139
+ const cluster = getActiveCluster();
140
+ assert.strictEqual(cluster.name, "prod");
141
+ assert.strictEqual(cluster.host, "h");
142
+ });
143
+
144
+ it("returns a specific cluster when name is provided", () => {
145
+ saveConfig({ activeCluster: "prod", clusters: { prod: { host: "h1", username: "u", password: "p" }, lab: { host: "h2", username: "u", password: "p" } } });
146
+ const cluster = getActiveCluster("lab");
147
+ assert.strictEqual(cluster.name, "lab");
148
+ assert.strictEqual(cluster.host, "h2");
149
+ });
150
+ });
151
+
152
+ describe("listClusters", () => {
153
+ it("returns activeCluster and clusters map", () => {
154
+ const result = listClusters();
155
+ assert.ok("activeCluster" in result);
156
+ assert.ok("clusters" in result);
157
+ assert.strictEqual(typeof result.clusters, "object");
158
+ });
159
+ });
160
+
161
+ describe("maskPassword", () => {
162
+ it("masks a plain password with asterisks", () => {
163
+ assert.strictEqual(maskPassword("secret"), "******");
164
+ });
165
+
166
+ it("returns empty string for falsy input", () => {
167
+ assert.strictEqual(maskPassword(""), "");
168
+ assert.strictEqual(maskPassword(null), "");
169
+ assert.strictEqual(maskPassword(undefined), "");
170
+ });
171
+
172
+ it("preserves ss-cli placeholders unmasked", () => {
173
+ const placeholder = "<ss:123:password>";
174
+ assert.strictEqual(maskPassword(placeholder), placeholder);
175
+ });
176
+ });
177
+
178
+ describe("hasSsPlaceholders", () => {
179
+ it("detects <ss:ID:field> patterns", () => {
180
+ assert.strictEqual(hasSsPlaceholders({ password: "<ss:123:password>" }), true);
181
+ });
182
+
183
+ it("returns false for plain strings", () => {
184
+ assert.strictEqual(hasSsPlaceholders({ password: "plaintext" }), false);
185
+ });
186
+
187
+ it("returns false for empty object", () => {
188
+ assert.strictEqual(hasSsPlaceholders({}), false);
189
+ });
190
+ });
191
+
192
+ // Cleanup
193
+ fs.rmSync(tmpDir, { recursive: true, force: true });
194
+
195
+ console.log(`\n config.test.js: ${total} tests, ${passed} passed, ${failed} failed`);
196
+ if (failed > 0) process.exit(1);