cisco-ise 1.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.
Files changed (44) hide show
  1. package/EXAMPLES.md +46 -0
  2. package/RADIUS.md +19 -0
  3. package/README.md +222 -0
  4. package/bin/cisco-ise.js +14 -0
  5. package/cli/commands/auth-profile.js +36 -0
  6. package/cli/commands/config.js +140 -0
  7. package/cli/commands/deployment.js +49 -0
  8. package/cli/commands/endpoint.js +167 -0
  9. package/cli/commands/guest.js +220 -0
  10. package/cli/commands/identity-group.js +24 -0
  11. package/cli/commands/internal-user.js +162 -0
  12. package/cli/commands/network-device.js +167 -0
  13. package/cli/commands/radius.js +326 -0
  14. package/cli/commands/session.js +123 -0
  15. package/cli/commands/tacacs.js +125 -0
  16. package/cli/commands/trustsec.js +37 -0
  17. package/cli/formatters/csv.js +10 -0
  18. package/cli/formatters/json.js +5 -0
  19. package/cli/formatters/table.js +29 -0
  20. package/cli/formatters/toon.js +6 -0
  21. package/cli/index.js +44 -0
  22. package/cli/utils/api.js +297 -0
  23. package/cli/utils/audit.js +30 -0
  24. package/cli/utils/config.js +125 -0
  25. package/cli/utils/confirm.js +34 -0
  26. package/cli/utils/connection.js +47 -0
  27. package/cli/utils/failure-reasons.js +2086 -0
  28. package/cli/utils/mac.js +18 -0
  29. package/cli/utils/output.js +42 -0
  30. package/cli/utils/spinner.js +19 -0
  31. package/cli/utils/time.js +21 -0
  32. package/cli/utils/wordlist.js +9 -0
  33. package/docs/PHASES.md +38 -0
  34. package/package.json +45 -0
  35. package/skills/cisco-ise-cli/SKILL.md +346 -0
  36. package/test/cli/api.test.js +67 -0
  37. package/test/cli/audit.test.js +31 -0
  38. package/test/cli/config.test.js +60 -0
  39. package/test/cli/confirm.test.js +34 -0
  40. package/test/cli/connection.test.js +54 -0
  41. package/test/cli/formatters.test.js +41 -0
  42. package/test/cli/mac.test.js +37 -0
  43. package/test/cli/time.test.js +30 -0
  44. package/test/integration/ise.test.js +425 -0
package/EXAMPLES.md ADDED
@@ -0,0 +1,46 @@
1
+ # Setup
2
+ cisco-ise config add lab --host ise01.automate.builders --username admin --password '<your-pass>' --insecure
3
+ cisco-ise config test
4
+
5
+ # Endpoints
6
+ cisco-ise endpoint list
7
+ cisco-ise endpoint list --format json --limit 10
8
+ cisco-ise endpoint search --mac E2:7C:7E:5B:F0:E0
9
+
10
+ # Identity groups & auth profiles
11
+ cisco-ise identity-group list
12
+ cisco-ise identity-group list --type endpoint
13
+ cisco-ise auth-profile list
14
+
15
+ # Network devices
16
+ cisco-ise network-device list
17
+
18
+ # Deployment
19
+ cisco-ise deployment nodes
20
+ cisco-ise deployment status
21
+
22
+ # Sessions
23
+ cisco-ise session list
24
+
25
+ # RADIUS
26
+ cisco-ise radius failures --last 1h
27
+
28
+ # TACACS
29
+ cisco-ise tacacs command-sets
30
+ cisco-ise tacacs profiles
31
+
32
+ # TrustSec
33
+ cisco-ise trustsec sgt list
34
+ cisco-ise trustsec sgacl list
35
+
36
+ # Guest
37
+ cisco-ise guest portals
38
+ cisco-ise guest list
39
+
40
+ # Dry run a write operation (safe — no changes)
41
+ cisco-ise endpoint add --mac AA:BB:CC:DD:EE:FF --group "Unknown" --dry-run
42
+
43
+ # Output formats
44
+ cisco-ise endpoint list --format json
45
+ cisco-ise endpoint list --format csv
46
+ cisco-ise endpoint list --format toon
package/RADIUS.md ADDED
@@ -0,0 +1,19 @@
1
+ # Get your machine's IP
2
+ ipconfig getifaddr en0
3
+
4
+ # Add it as a NAD on ISE
5
+ cisco-ise network-device add --name "macbook-test" --ip 192.168.40.140 --radius-secret "testing123"
6
+ 2. Test RADIUS authentication:
7
+
8
+ You'll need a RADIUS test client. The easiest is radtest from FreeRADIUS:
9
+
10
+
11
+ brew install freeradius-server
12
+
13
+ # Test auth against ISE (port 1812)
14
+ radtest admin Cisco123 ise01.automate.builders 0 testing123
15
+ 3. Check the logs:
16
+
17
+
18
+ cisco-ise radius failures --last 30m
19
+ cisco-ise session list
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # cisco-ise
2
+
3
+ [![npm](https://img.shields.io/npm/v/cisco-ise.svg)](https://www.npmjs.com/package/cisco-ise)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-donate-orange.svg)](https://buymeacoffee.com/automatebldrs)
6
+
7
+ CLI for Cisco ISE (Identity Services Engine) 3.1+ — day-to-day operations and troubleshooting via ERS, OpenAPI, and MNT APIs.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install -g cisco-ise
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ # Add a cluster
19
+ cisco-ise config add lab --host 10.0.0.1 --username admin --password '<ss:ID:password>' --insecure
20
+
21
+ # Test connection
22
+ cisco-ise config test
23
+
24
+ # List endpoints
25
+ cisco-ise endpoint list
26
+
27
+ # Check RADIUS failures
28
+ cisco-ise radius failures --last 1h
29
+ ```
30
+
31
+ ## Commands
32
+
33
+ | Command | Description |
34
+ |---------|-------------|
35
+ | `config` | Manage cluster configurations (add/use/list/show/remove/test/update/clear-cache) |
36
+ | `endpoint` | Manage endpoints (list/search/add/update/delete, CSV bulk import) |
37
+ | `guest` | Manage guest users (list/search/create/extend/suspend/reinstate/delete/portals) |
38
+ | `network-device` | Manage network access devices (list/search/get/add/update/delete) |
39
+ | `session` | Active sessions (list/search/disconnect/reauth via CoA) |
40
+ | `radius` | RADIUS monitoring (failures with human-readable reasons, live polling) |
41
+ | `tacacs` | TACACS+ monitoring (failures/live/command-sets/profiles) |
42
+ | `identity-group` | List identity groups (--type endpoint/user) |
43
+ | `auth-profile` | List/get authorization profiles |
44
+ | `trustsec` | TrustSec SGTs and SGACLs (read-only) |
45
+ | `deployment` | ISE deployment nodes and status (read-only) |
46
+
47
+ ## Configuration
48
+
49
+ ### Config file
50
+
51
+ ```bash
52
+ cisco-ise config add prod --host ise.example.com --username admin --password '<ss:ID:password>' --insecure --read-only
53
+ cisco-ise config add lab --host 10.0.0.1 --username admin --password '<ss:ID:password>' --insecure
54
+ cisco-ise config use lab
55
+ cisco-ise config list
56
+ ```
57
+
58
+ Config stored in `~/.cisco-ise/config.json` (mode 0600).
59
+
60
+ ### Environment variables
61
+
62
+ ```bash
63
+ export CISCO_ISE_HOST=<host>
64
+ export CISCO_ISE_USERNAME=<user>
65
+ export CISCO_ISE_PASSWORD=<password>
66
+ ```
67
+
68
+ ### Secret Server (ss-cli)
69
+
70
+ Passwords can reference Delinea Secret Server:
71
+
72
+ ```bash
73
+ cisco-ise config add prod --host ise.example.com --username admin --password '<ss:1234:password>' --insecure
74
+ ```
75
+
76
+ ### Precedence
77
+
78
+ CLI flags > environment variables > config file.
79
+
80
+ ## MAC Address Formats
81
+
82
+ Any common format is accepted and automatically normalized to `AA:BB:CC:DD:EE:FF`:
83
+
84
+ - `AA:BB:CC:DD:EE:FF` — colon-separated
85
+ - `AA-BB-CC-DD-EE-FF` — dash-separated
86
+ - `AABB.CCDD.EEFF` — Cisco dot notation
87
+ - `aabbccddeeff` — bare hex
88
+
89
+ ## Endpoint Management
90
+
91
+ ```bash
92
+ # List all endpoints
93
+ cisco-ise endpoint list
94
+ cisco-ise endpoint list --limit 50 --format json
95
+
96
+ # Search by MAC or group
97
+ cisco-ise endpoint search --mac AA:BB:CC:DD:EE:FF
98
+ cisco-ise endpoint search --group "Profiled"
99
+
100
+ # Add endpoint
101
+ cisco-ise endpoint add --mac AA:BB:CC:DD:EE:FF --group "Unknown" --description "Test device"
102
+
103
+ # Bulk import from CSV
104
+ cisco-ise endpoint add --csv endpoints.csv
105
+
106
+ # Update and delete
107
+ cisco-ise endpoint update AA:BB:CC:DD:EE:FF --group "Profiled"
108
+ cisco-ise endpoint delete AA:BB:CC:DD:EE:FF
109
+ ```
110
+
111
+ ## Guest User Management
112
+
113
+ ```bash
114
+ # List portals and guests
115
+ cisco-ise guest portals
116
+ cisco-ise guest list
117
+
118
+ # Create a guest
119
+ cisco-ise guest create --first "John" --last "Doe" --email "john@example.com" --portal "Sponsored Guest Portal (default)"
120
+
121
+ # Manage guest lifecycle
122
+ cisco-ise guest extend <id> --duration 1d
123
+ cisco-ise guest suspend <id>
124
+ cisco-ise guest reinstate <id>
125
+ cisco-ise guest delete <id>
126
+ ```
127
+
128
+ ## Session Management
129
+
130
+ ```bash
131
+ # List active sessions
132
+ cisco-ise session list
133
+ cisco-ise session search --mac E2:7C:7E:5B:F0:E0
134
+ cisco-ise session search --user jdoe
135
+
136
+ # CoA operations
137
+ cisco-ise session disconnect E2:7C:7E:5B:F0:E0
138
+ cisco-ise session reauth E2:7C:7E:5B:F0:E0
139
+ ```
140
+
141
+ ## RADIUS & TACACS+ Monitoring
142
+
143
+ ```bash
144
+ # RADIUS failures with human-readable reasons
145
+ cisco-ise radius failures --last 1h
146
+ cisco-ise radius failures --last 30m --user jdoe
147
+
148
+ # Live RADIUS monitoring (Ctrl+C to stop)
149
+ cisco-ise radius live
150
+
151
+ # TACACS+
152
+ cisco-ise tacacs failures --last 2h
153
+ cisco-ise tacacs command-sets
154
+ cisco-ise tacacs profiles
155
+ ```
156
+
157
+ ## Network Devices
158
+
159
+ ```bash
160
+ cisco-ise network-device list
161
+ cisco-ise network-device search --name "switch"
162
+ cisco-ise network-device add --name "switch01" --ip 10.0.0.1 --radius-secret '<ss:ID:radius-secret>'
163
+ cisco-ise network-device delete "switch01"
164
+ ```
165
+
166
+ ## Read-Only Protection
167
+
168
+ Clusters marked `--read-only` require typing a random word before any write operation:
169
+
170
+ ```bash
171
+ cisco-ise config add prod --host ise.example.com --username admin --password '<ss:ID:password>' --read-only --insecure
172
+ ```
173
+
174
+ Non-interactive environments are blocked entirely.
175
+
176
+ ## Dry Run
177
+
178
+ Preview write operations without executing:
179
+
180
+ ```bash
181
+ cisco-ise endpoint add --mac AA:BB:CC:DD:EE:FF --group "Unknown" --dry-run
182
+ # Output: DRY RUN — no changes made
183
+ # POST https://ise.example.com:9060/ers/config/endpoint
184
+ # { ... payload ... }
185
+ ```
186
+
187
+ ## Output Formats
188
+
189
+ ```bash
190
+ cisco-ise endpoint list --format table # default, human-readable
191
+ cisco-ise endpoint list --format json # for scripting
192
+ cisco-ise endpoint list --format csv # for spreadsheets
193
+ cisco-ise endpoint list --format toon # token-efficient for AI agents
194
+ ```
195
+
196
+ ## Global Flags
197
+
198
+ | Flag | Description |
199
+ |------|-------------|
200
+ | `--format <type>` | Output format: table, json, toon, csv |
201
+ | `--host <host>` | ISE hostname or IP |
202
+ | `--username <user>` | ISE username |
203
+ | `--password <pass>` | ISE password |
204
+ | `--cluster <name>` | Use a named cluster |
205
+ | `--insecure` | Skip TLS certificate verification |
206
+ | `--read-only` | Block write operations |
207
+ | `--dry-run` | Show what would happen without executing |
208
+ | `--no-audit` | Disable audit logging |
209
+ | `--no-cache` | Bypass response cache |
210
+ | `--debug` | Enable debug logging |
211
+
212
+ ## ISE API Details
213
+
214
+ - **ERS API** (port 9060) — endpoints, groups, network devices, guests, auth profiles, TrustSec
215
+ - **OpenAPI** (port 443) — deployment, modern endpoints
216
+ - **MNT API** (port 443) — sessions, CoA, RADIUS/TACACS monitoring
217
+
218
+ ERS must be enabled in ISE Admin > Settings > ERS Settings.
219
+
220
+ ## License
221
+
222
+ MIT
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Suppress Node.js TLS warning — users opt into insecure mode explicitly
4
+ // via --insecure flag or cluster config, so the warning is redundant noise
5
+ const originalEmit = process.emit;
6
+ process.emit = function (event, warning) {
7
+ if (event === "warning" && warning?.name === "Warning" &&
8
+ warning?.message?.includes("NODE_TLS_REJECT_UNAUTHORIZED")) {
9
+ return false;
10
+ }
11
+ return originalEmit.apply(this, arguments);
12
+ };
13
+
14
+ require("../cli/index.js");
@@ -0,0 +1,36 @@
1
+ const { resolveConnection } = require("../utils/connection.js");
2
+ const { printResult, printError } = require("../utils/output.js");
3
+ const IseClient = require("../utils/api.js");
4
+
5
+ module.exports = function (program) {
6
+ const cmd = program.command("auth-profile").description("List ISE authorization profiles");
7
+
8
+ cmd.command("list")
9
+ .description("List all authorization profiles")
10
+ .action(async (opts, command) => {
11
+ try {
12
+ const globalOpts = command.optsWithGlobals();
13
+ const conn = resolveConnection(globalOpts);
14
+ const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
15
+
16
+ const resources = await client.ersPaginateAll("/authorizationprofile");
17
+ await printResult(resources, globalOpts.format);
18
+ } catch (err) { printError(err); }
19
+ });
20
+
21
+ cmd.command("get <name>")
22
+ .description("Get authorization profile details")
23
+ .action(async (name, opts, command) => {
24
+ try {
25
+ const globalOpts = command.optsWithGlobals();
26
+ const conn = resolveConnection(globalOpts);
27
+ const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
28
+
29
+ const data = await client.ersGet("/authorizationprofile", { filter: `name.EQ.${name}`, size: 1 });
30
+ const resources = data?.SearchResult?.resources;
31
+ if (!resources?.length) throw new Error(`Authorization profile "${name}" not found.`);
32
+ const detail = await client.ersGet(`/authorizationprofile/${resources[0].id}`);
33
+ await printResult(detail?.AuthorizationProfile || detail, globalOpts.format);
34
+ } catch (err) { printError(err); }
35
+ });
36
+ };
@@ -0,0 +1,140 @@
1
+ const { resolveConnection } = require("../utils/connection.js");
2
+ const config = require("../utils/config.js");
3
+ const { printResult, printError } = require("../utils/output.js");
4
+ const audit = require("../utils/audit.js");
5
+ const IseClient = require("../utils/api.js");
6
+
7
+ module.exports = function (program) {
8
+ const cmd = program.command("config").description("Manage ISE cluster configurations");
9
+
10
+ cmd.command("add <name>")
11
+ .description("Add a named ISE cluster")
12
+ .action((name, opts, command) => {
13
+ try {
14
+ const globalOpts = command.optsWithGlobals();
15
+ const { host, username, password, ppan, pmnt, insecure, readOnly } = globalOpts;
16
+ if (!host) throw new Error("--host is required");
17
+ if (!username) throw new Error("--username is required");
18
+ if (!password) throw new Error("--password is required");
19
+ config.addCluster(name, { host, username, password, ppan, pmnt, insecure, readOnly });
20
+ console.log(`Cluster "${name}" added successfully.`);
21
+ } catch (err) { printError(err); }
22
+ });
23
+
24
+ cmd.command("use <name>")
25
+ .description("Set the active cluster")
26
+ .action((name) => {
27
+ try {
28
+ config.useCluster(name);
29
+ console.log(`Active cluster set to "${name}".`);
30
+ } catch (err) { printError(err); }
31
+ });
32
+
33
+ cmd.command("list")
34
+ .description("List all configured clusters")
35
+ .action(async (opts, command) => {
36
+ try {
37
+ const clusters = config.listClusters();
38
+ const active = config.loadConfig().activeCluster;
39
+ const rows = Object.entries(clusters).map(([name, c]) => ({
40
+ name: name === active ? `${name} *` : name,
41
+ host: c.host,
42
+ username: c.username,
43
+ readOnly: c.readOnly ? "yes" : "no",
44
+ }));
45
+ await printResult(rows, command.optsWithGlobals().format);
46
+ } catch (err) { printError(err); }
47
+ });
48
+
49
+ cmd.command("show")
50
+ .description("Show active cluster details")
51
+ .action(async (opts, command) => {
52
+ try {
53
+ const data = config.loadConfig();
54
+ const cluster = data.clusters[data.activeCluster];
55
+ if (!cluster) throw new Error("No active cluster. Run: cisco-ise config add <name> ...");
56
+ const details = {
57
+ name: data.activeCluster,
58
+ host: cluster.host,
59
+ username: cluster.username,
60
+ password: config.maskPassword(cluster.password),
61
+ insecure: cluster.insecure || false,
62
+ readOnly: cluster.readOnly || false,
63
+ };
64
+ if (cluster.ppan) details.ppan = cluster.ppan;
65
+ if (cluster.pmnt) details.pmnt = cluster.pmnt;
66
+ if (cluster.sponsorUser) details.sponsorUser = cluster.sponsorUser;
67
+ if (cluster.sponsorPassword) details.sponsorPassword = config.maskPassword(cluster.sponsorPassword);
68
+ await printResult(details, command.optsWithGlobals().format);
69
+ } catch (err) { printError(err); }
70
+ });
71
+
72
+ cmd.command("remove <name>")
73
+ .description("Remove a cluster from config")
74
+ .action((name) => {
75
+ try {
76
+ config.removeCluster(name);
77
+ console.log(`Cluster "${name}" removed.`);
78
+ } catch (err) { printError(err); }
79
+ });
80
+
81
+ cmd.command("test")
82
+ .description("Test connection to active cluster")
83
+ .action(async (opts, command) => {
84
+ try {
85
+ const globalOpts = command.optsWithGlobals();
86
+ const conn = resolveConnection(globalOpts);
87
+ const client = new IseClient(conn, { debug: globalOpts.debug });
88
+
89
+ let ersOk = false, openApiOk = false;
90
+ try {
91
+ await client.ersGet("/endpoint", { size: 1 });
92
+ ersOk = true;
93
+ } catch { /* ERS not available */ }
94
+
95
+ try {
96
+ await client.openApiGet("/deployment/node");
97
+ openApiOk = true;
98
+ } catch { /* OpenAPI not available */ }
99
+
100
+ if (!ersOk && !openApiOk) {
101
+ throw new Error("Connection failed. Check host, credentials, and that ERS is enabled.");
102
+ }
103
+ console.log("Connection successful.");
104
+ console.log(` ERS API (port 9060): ${ersOk ? "\u2713" : "\u2717"}`);
105
+ console.log(` OpenAPI (port 443): ${openApiOk ? "\u2713" : "\u2717"}`);
106
+ } catch (err) { printError(err); }
107
+ });
108
+
109
+ cmd.command("update <name>")
110
+ .description("Update cluster settings")
111
+ .action((name, opts, command) => {
112
+ try {
113
+ const globalOpts = command.optsWithGlobals();
114
+ const updates = {};
115
+ if (globalOpts.host) updates.host = globalOpts.host;
116
+ if (globalOpts.username) updates.username = globalOpts.username;
117
+ if (globalOpts.password) updates.password = globalOpts.password;
118
+ if (globalOpts.ppan) updates.ppan = globalOpts.ppan;
119
+ if (globalOpts.pmnt) updates.pmnt = globalOpts.pmnt;
120
+ if (globalOpts.sponsorUser) updates.sponsorUser = globalOpts.sponsorUser;
121
+ if (globalOpts.sponsorPassword) updates.sponsorPassword = globalOpts.sponsorPassword;
122
+ if (globalOpts.insecure !== undefined) updates.insecure = globalOpts.insecure;
123
+ if (globalOpts.readOnly !== undefined) updates.readOnly = globalOpts.readOnly;
124
+ config.updateCluster(name, updates);
125
+ console.log(`Cluster "${name}" updated.`);
126
+ } catch (err) { printError(err); }
127
+ });
128
+
129
+ cmd.command("clear-cache")
130
+ .description("Clear response cache")
131
+ .action((opts, command) => {
132
+ try {
133
+ const globalOpts = command.optsWithGlobals();
134
+ const conn = resolveConnection(globalOpts);
135
+ const client = new IseClient(conn);
136
+ client.invalidateCache();
137
+ console.log("Cache cleared.");
138
+ } catch (err) { printError(err); }
139
+ });
140
+ };
@@ -0,0 +1,49 @@
1
+ const { resolveConnection } = require("../utils/connection.js");
2
+ const { printResult, printError } = require("../utils/output.js");
3
+ const IseClient = require("../utils/api.js");
4
+
5
+ module.exports = function (program) {
6
+ const cmd = program.command("deployment").description("ISE deployment information (read-only)");
7
+
8
+ cmd.command("nodes")
9
+ .description("List ISE deployment nodes")
10
+ .action(async (opts, command) => {
11
+ try {
12
+ const globalOpts = command.optsWithGlobals();
13
+ const conn = resolveConnection(globalOpts);
14
+ const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
15
+
16
+ const data = await client.openApiGet("/deployment/node");
17
+ const nodes = data?.response || data || [];
18
+ const result = Array.isArray(nodes) ? nodes : [nodes];
19
+ await printResult(result.map((n) => ({
20
+ hostname: n.hostname || n.fqdn || "",
21
+ fqdn: n.fqdn || "",
22
+ ip: n.ipAddress || n.ipAddresses?.[0] || "",
23
+ roles: Array.isArray(n.roles) ? n.roles.join(", ") : (n.roles || ""),
24
+ services: Array.isArray(n.services) ? n.services.join(", ") : (n.services || ""),
25
+ status: n.nodeStatus || "",
26
+ })), globalOpts.format);
27
+ } catch (err) { printError(err); }
28
+ });
29
+
30
+ cmd.command("status")
31
+ .description("Show ISE deployment status")
32
+ .action(async (opts, command) => {
33
+ try {
34
+ const globalOpts = command.optsWithGlobals();
35
+ const conn = resolveConnection(globalOpts);
36
+ const client = new IseClient(conn, { noCache: !globalOpts.cache, debug: globalOpts.debug });
37
+
38
+ const data = await client.openApiGet("/deployment/node");
39
+ const nodes = data?.response || data || [];
40
+ const result = Array.isArray(nodes) ? nodes : [nodes];
41
+ await printResult(result.map((n) => ({
42
+ hostname: n.hostname || n.fqdn || "",
43
+ status: n.nodeStatus || "unknown",
44
+ roles: Array.isArray(n.roles) ? n.roles.join(", ") : (n.roles || ""),
45
+ services: Array.isArray(n.services) ? n.services.join(", ") : (n.services || ""),
46
+ })), globalOpts.format);
47
+ } catch (err) { printError(err); }
48
+ });
49
+ };