unbound-cli 0.1.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,282 @@
1
+ const readline = require('readline');
2
+ const config = require('../config');
3
+ const api = require('../api');
4
+ const output = require('../output');
5
+
6
+ function formatDate(dateStr) {
7
+ if (!dateStr) return '-';
8
+ return new Date(dateStr).toLocaleDateString();
9
+ }
10
+
11
+ function displayGroup(group) {
12
+ output.keyValue([
13
+ ['ID', String(group.id)],
14
+ ['Name', group.name],
15
+ ['All Org Users', String(group.all_org_users)],
16
+ ['Member Count', String(group.member_count ?? '-')],
17
+ ['Created', formatDate(group.created_at)],
18
+ ['Updated', formatDate(group.updated_at)],
19
+ ]);
20
+
21
+ if (!group.all_org_users && group.members && group.members.length > 0) {
22
+ console.log('\nMembers:');
23
+ output.table(group.members, [
24
+ { key: 'id', header: 'ID' },
25
+ { key: 'email', header: 'Email' },
26
+ { key: 'first_name', header: 'First Name', format: (v) => v || '-' },
27
+ { key: 'last_name', header: 'Last Name', format: (v) => v || '-' },
28
+ ]);
29
+ }
30
+ }
31
+
32
+ function confirm(message) {
33
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
34
+ return new Promise((resolve) => {
35
+ rl.question(`${message} (y/N) `, (answer) => {
36
+ rl.close();
37
+ resolve(answer.toLowerCase() === 'y');
38
+ });
39
+ });
40
+ }
41
+
42
+ function parseCommaSeparated(value) {
43
+ if (!value) return undefined;
44
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
45
+ }
46
+
47
+ function register(program) {
48
+ const userGroups = program
49
+ .command('user-groups')
50
+ .alias('groups')
51
+ .description('Manage user groups. Groups organize users for policy scoping and access control.');
52
+
53
+ // user-groups list
54
+ userGroups
55
+ .command('list')
56
+ .description('List all user groups. Supports searching by name.')
57
+ .option('--search <term>', 'Search groups by name')
58
+ .option('--json', 'Output raw JSON instead of a table')
59
+ .addHelpText('after', `
60
+ Examples:
61
+ $ unbound user-groups list
62
+ $ unbound groups list
63
+ $ unbound user-groups list --search "engineering"
64
+ $ unbound user-groups list --json
65
+ `)
66
+ .action(async (opts) => {
67
+ try {
68
+ if (!config.isLoggedIn()) {
69
+ output.error('Not logged in. Run `unbound login` first.');
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+
74
+ const query = {};
75
+ if (opts.search) query.search = opts.search;
76
+
77
+ const data = await api.get('/api/v1/user_groups/', { query });
78
+
79
+ if (opts.json) {
80
+ output.json(data);
81
+ return;
82
+ }
83
+
84
+ output.table(data.user_groups, [
85
+ { key: 'id', header: 'ID' },
86
+ { key: 'name', header: 'Name' },
87
+ { key: 'all_org_users', header: 'All Org Users' },
88
+ { key: 'member_count', header: 'Member Count', format: (v) => (v != null ? String(v) : '-') },
89
+ { key: 'created_at', header: 'Created', format: (v) => formatDate(v) },
90
+ ]);
91
+ } catch (err) {
92
+ output.error(err.message);
93
+ process.exitCode = 1;
94
+ }
95
+ });
96
+
97
+ // user-groups get
98
+ userGroups
99
+ .command('get <id>')
100
+ .description('Get detailed information about a specific user group, including its members.')
101
+ .option('--json', 'Output raw JSON instead of formatted key-value pairs')
102
+ .addHelpText('after', `
103
+ Examples:
104
+ $ unbound user-groups get 1
105
+ $ unbound groups get 1 --json
106
+ `)
107
+ .action(async (id, opts) => {
108
+ try {
109
+ if (!config.isLoggedIn()) {
110
+ output.error('Not logged in. Run `unbound login` first.');
111
+ process.exitCode = 1;
112
+ return;
113
+ }
114
+
115
+ const data = await api.get(`/api/v1/user_groups/${id}/`);
116
+
117
+ if (opts.json) {
118
+ output.json(data);
119
+ return;
120
+ }
121
+
122
+ displayGroup(data.user_group);
123
+ } catch (err) {
124
+ output.error(err.message);
125
+ process.exitCode = 1;
126
+ }
127
+ });
128
+
129
+ // user-groups create
130
+ userGroups
131
+ .command('create')
132
+ .description('Create a new user group. By default, the group includes all organization users.')
133
+ .requiredOption('--name <name>', 'Group name (required)')
134
+ .option('--all-org-users', 'Include all organization users (default: true)', true)
135
+ .option('--no-all-org-users', 'Do not include all organization users')
136
+ .option('--user-ids <ids>', 'Comma-separated user IDs to add to this group')
137
+ .addHelpText('after', `
138
+ Examples:
139
+ $ unbound user-groups create --name "Engineering"
140
+ $ unbound groups create --name "Backend Team" --no-all-org-users --user-ids 1,2,3
141
+ `)
142
+ .action(async (opts) => {
143
+ try {
144
+ if (!config.isLoggedIn()) {
145
+ output.error('Not logged in. Run `unbound login` first.');
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+
150
+ const body = {
151
+ name: opts.name,
152
+ all_org_users: opts.allOrgUsers,
153
+ };
154
+
155
+ if (opts.userIds) {
156
+ body.user_ids = parseCommaSeparated(opts.userIds).map(Number);
157
+ }
158
+
159
+ const data = await api.post('/api/v1/user_groups/', { body });
160
+ output.success('User group created.');
161
+ displayGroup(data.user_group);
162
+ } catch (err) {
163
+ output.error(err.message);
164
+ process.exitCode = 1;
165
+ }
166
+ });
167
+
168
+ // user-groups update
169
+ userGroups
170
+ .command('update <id>')
171
+ .description('Update an existing user group. Only provided fields will be changed.')
172
+ .option('--name <name>', 'Update group name')
173
+ .option('--all-org-users', 'Include all organization users')
174
+ .option('--no-all-org-users', 'Do not include all organization users')
175
+ .option('--user-ids <ids>', 'Comma-separated user IDs to set as group members')
176
+ .addHelpText('after', `
177
+ Examples:
178
+ $ unbound user-groups update 1 --name "New Name"
179
+ $ unbound groups update 1 --no-all-org-users --user-ids 1,2,3
180
+ $ unbound user-groups update 1 --all-org-users
181
+ `)
182
+ .action(async (id, opts) => {
183
+ try {
184
+ if (!config.isLoggedIn()) {
185
+ output.error('Not logged in. Run `unbound login` first.');
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+
190
+ const body = {};
191
+ if (opts.name !== undefined) body.name = opts.name;
192
+ if (opts.allOrgUsers !== undefined) body.all_org_users = opts.allOrgUsers;
193
+
194
+ if (opts.userIds) {
195
+ body.user_ids = parseCommaSeparated(opts.userIds).map(Number);
196
+ }
197
+
198
+ const data = await api.put(`/api/v1/user_groups/${id}/`, { body });
199
+ output.success('User group updated.');
200
+ displayGroup(data.user_group);
201
+ } catch (err) {
202
+ output.error(err.message);
203
+ process.exitCode = 1;
204
+ }
205
+ });
206
+
207
+ // user-groups delete
208
+ userGroups
209
+ .command('delete <id>')
210
+ .description('Delete a user group by its ID. Prompts for confirmation unless --yes is provided.')
211
+ .option('--yes', 'Skip confirmation prompt')
212
+ .addHelpText('after', `
213
+ Examples:
214
+ $ unbound user-groups delete 1
215
+ $ unbound groups delete 1 --yes
216
+ `)
217
+ .action(async (id, opts) => {
218
+ try {
219
+ if (!config.isLoggedIn()) {
220
+ output.error('Not logged in. Run `unbound login` first.');
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+
225
+ if (!opts.yes) {
226
+ const confirmed = await confirm(`Are you sure you want to delete user group ${id}?`);
227
+ if (!confirmed) {
228
+ output.warn('Aborted.');
229
+ return;
230
+ }
231
+ }
232
+
233
+ await api.del(`/api/v1/user_groups/${id}/`);
234
+ output.success(`User group ${id} deleted.`);
235
+ } catch (err) {
236
+ output.error(err.message);
237
+ process.exitCode = 1;
238
+ }
239
+ });
240
+
241
+ // user-groups effective-policies
242
+ userGroups
243
+ .command('effective-policies <id>')
244
+ .description('View the effective policies applied to a specific user group. Shows the resolved set of policies after inheritance and priority.')
245
+ .option('--json', 'Output raw JSON')
246
+ .addHelpText('after', `
247
+ Examples:
248
+ $ unbound user-groups effective-policies 1
249
+ $ unbound groups effective-policies 1 --json
250
+ `)
251
+ .action(async (id, opts) => {
252
+ try {
253
+ if (!config.isLoggedIn()) {
254
+ output.error('Not logged in. Run `unbound login` first.');
255
+ process.exitCode = 1;
256
+ return;
257
+ }
258
+
259
+ const data = await api.get(`/api/v1/user_groups/${id}/effective_policies/`);
260
+
261
+ if (opts.json) {
262
+ output.json(data);
263
+ return;
264
+ }
265
+
266
+ const policies = data.effective_policies || data.policies || [];
267
+ output.table(policies, [
268
+ { key: 'id', header: 'ID' },
269
+ { key: 'name', header: 'Name' },
270
+ { key: 'type', header: 'Type' },
271
+ { key: 'enabled', header: 'Enabled' },
272
+ { key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
273
+ { key: 'config_summary', header: 'Config Summary', format: (v) => v || '-' },
274
+ ]);
275
+ } catch (err) {
276
+ output.error(err.message);
277
+ process.exitCode = 1;
278
+ }
279
+ });
280
+ }
281
+
282
+ module.exports = { register };
@@ -0,0 +1,88 @@
1
+ const config = require('../config');
2
+ const api = require('../api');
3
+ const output = require('../output');
4
+
5
+ function register(program) {
6
+ const users = program
7
+ .command('users')
8
+ .description('Manage organization users. List members and view their effective policies.');
9
+
10
+ // users list
11
+ users
12
+ .command('list')
13
+ .description('List all members in the organization.')
14
+ .option('--json', 'Output raw JSON instead of a table')
15
+ .addHelpText('after', `
16
+ Examples:
17
+ $ unbound users list
18
+ $ unbound users list --json
19
+ `)
20
+ .action(async (opts) => {
21
+ try {
22
+ if (!config.isLoggedIn()) {
23
+ output.error('Not logged in. Run `unbound login` first.');
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ const data = await api.get('/api/v1/user_groups/members/');
29
+
30
+ if (opts.json) {
31
+ output.json(data);
32
+ return;
33
+ }
34
+
35
+ output.table(data.members, [
36
+ { key: 'id', header: 'ID' },
37
+ { key: 'email', header: 'Email' },
38
+ { key: 'first_name', header: 'First Name', format: (v) => v || '-' },
39
+ { key: 'last_name', header: 'Last Name', format: (v) => v || '-' },
40
+ ]);
41
+ } catch (err) {
42
+ output.error(err.message);
43
+ process.exitCode = 1;
44
+ }
45
+ });
46
+
47
+ // users effective-policies
48
+ users
49
+ .command('effective-policies <user-id>')
50
+ .description('View the effective policies applied to a specific user. Shows the resolved set of policies after inheritance and priority.')
51
+ .option('--json', 'Output raw JSON')
52
+ .addHelpText('after', `
53
+ Examples:
54
+ $ unbound users effective-policies 42
55
+ $ unbound users effective-policies 42 --json
56
+ `)
57
+ .action(async (userId, opts) => {
58
+ try {
59
+ if (!config.isLoggedIn()) {
60
+ output.error('Not logged in. Run `unbound login` first.');
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+
65
+ const data = await api.get(`/api/v1/users/${userId}/effective_policies/`);
66
+
67
+ if (opts.json) {
68
+ output.json(data);
69
+ return;
70
+ }
71
+
72
+ const policies = data.effective_policies || data.policies || [];
73
+ output.table(policies, [
74
+ { key: 'id', header: 'ID' },
75
+ { key: 'name', header: 'Name' },
76
+ { key: 'type', header: 'Type' },
77
+ { key: 'enabled', header: 'Enabled' },
78
+ { key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
79
+ { key: 'config_summary', header: 'Config Summary', format: (v) => v || '-' },
80
+ ]);
81
+ } catch (err) {
82
+ output.error(err.message);
83
+ process.exitCode = 1;
84
+ }
85
+ });
86
+ }
87
+
88
+ module.exports = { register };
@@ -0,0 +1,49 @@
1
+ const config = require('../config');
2
+ const api = require('../api');
3
+ const output = require('../output');
4
+
5
+ function roleFromPrivileges(privileges) {
6
+ if (privileges.is_admin) return 'Admin';
7
+ if (privileges.is_manager) return 'Manager';
8
+ if (privileges.is_member) return 'Member';
9
+ return 'Unknown';
10
+ }
11
+
12
+ function register(program) {
13
+ program
14
+ .command('whoami')
15
+ .description('Display the currently authenticated user, organization, and role. Requires an active login session.')
16
+ .addHelpText('after', `
17
+ Output fields:
18
+ Email - The authenticated user's email address
19
+ Organization - The organization the user belongs to
20
+ Role - One of: Admin, Manager, Member
21
+
22
+ Examples:
23
+ $ unbound whoami
24
+ `)
25
+ .action(async () => {
26
+ try {
27
+ if (!config.isLoggedIn()) {
28
+ output.error('Not logged in. Run `unbound login` first.');
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+
33
+ const cfg = config.readConfig();
34
+ const privileges = await api.get('/api/v1/users/privileges/');
35
+ const role = roleFromPrivileges(privileges);
36
+
37
+ output.keyValue([
38
+ ['Email', cfg.email || '-'],
39
+ ['Organization', cfg.org_name || '-'],
40
+ ['Role', role],
41
+ ]);
42
+ } catch (err) {
43
+ output.error(err.message);
44
+ process.exitCode = 1;
45
+ }
46
+ });
47
+ }
48
+
49
+ module.exports = { register };
package/src/config.js ADDED
@@ -0,0 +1,87 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.unbound');
6
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
7
+ const DEFAULT_BASE_URL = 'https://backend.getunbound.ai';
8
+ const DEFAULT_FRONTEND_URL = 'https://gateway.getunbound.ai';
9
+
10
+ function ensureConfigDir() {
11
+ if (!fs.existsSync(CONFIG_DIR)) {
12
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
13
+ }
14
+ }
15
+
16
+ function readConfig() {
17
+ try {
18
+ if (fs.existsSync(CONFIG_FILE)) {
19
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
20
+ }
21
+ } catch {
22
+ // Corrupted config, start fresh
23
+ }
24
+ return {};
25
+ }
26
+
27
+ function writeConfig(config) {
28
+ ensureConfigDir();
29
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
30
+ }
31
+
32
+ function getApiKey() {
33
+ const config = readConfig();
34
+ return config.api_key || null;
35
+ }
36
+
37
+ function setApiKey(apiKey) {
38
+ const config = readConfig();
39
+ config.api_key = apiKey;
40
+ writeConfig(config);
41
+ }
42
+
43
+ function getBaseUrl() {
44
+ return process.env.UNBOUND_API_URL || readConfig().base_url || DEFAULT_BASE_URL;
45
+ }
46
+
47
+ function setBaseUrl(url) {
48
+ const config = readConfig();
49
+ config.base_url = url;
50
+ writeConfig(config);
51
+ }
52
+
53
+ function getFrontendUrl() {
54
+ return process.env.UNBOUND_FRONTEND_URL || readConfig().frontend_url || DEFAULT_FRONTEND_URL;
55
+ }
56
+
57
+ function setFrontendUrl(url) {
58
+ const config = readConfig();
59
+ config.frontend_url = url;
60
+ writeConfig(config);
61
+ }
62
+
63
+ function clearConfig() {
64
+ if (fs.existsSync(CONFIG_FILE)) {
65
+ fs.unlinkSync(CONFIG_FILE);
66
+ }
67
+ }
68
+
69
+ function isLoggedIn() {
70
+ return !!getApiKey();
71
+ }
72
+
73
+ module.exports = {
74
+ CONFIG_DIR,
75
+ CONFIG_FILE,
76
+ ensureConfigDir,
77
+ readConfig,
78
+ writeConfig,
79
+ getApiKey,
80
+ setApiKey,
81
+ getBaseUrl,
82
+ setBaseUrl,
83
+ getFrontendUrl,
84
+ setFrontendUrl,
85
+ clearConfig,
86
+ isLoggedIn,
87
+ };
package/src/index.js ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const config = require('./config');
5
+ const output = require('./output');
6
+
7
+ const program = new Command();
8
+
9
+ program
10
+ .name('unbound')
11
+ .description('Unbound CLI - Manage your AI Gateway from the command line.\n\nUnbound is an AI gateway that provides centralized policy management,\ncost controls, and security guardrails for AI coding tools like\nClaude Code, Cursor, Gemini CLI, and more.')
12
+ .version('0.1.0')
13
+ .addHelpText('after', `
14
+ Examples:
15
+ $ unbound login Sign in via browser
16
+ $ unbound login --api-key <key> Sign in with an API key
17
+ $ unbound whoami Show current user info
18
+ $ unbound policy list List all policies
19
+ $ unbound policy list --type SECURITY List security policies
20
+ $ unbound users list List organization users
21
+ $ unbound user-groups list List user groups
22
+ $ unbound tools list List connected AI tools
23
+ $ unbound setup cursor Configure Cursor to use Unbound
24
+ $ unbound setup claude-code Configure Claude Code to use Unbound
25
+ $ unbound config set-url http://localhost:8000 Use local backend
26
+ $ unbound config set-frontend-url http://localhost:3000 Use local frontend
27
+
28
+ Environment Variables:
29
+ UNBOUND_API_URL Override the API base URL (e.g. http://localhost:8000)
30
+ UNBOUND_FRONTEND_URL Override the frontend URL (e.g. http://localhost:3000)
31
+
32
+ Configuration:
33
+ Config is stored in ~/.unbound/config.json
34
+ Production backend: https://backend.getunbound.ai
35
+ Production frontend: https://gateway.getunbound.ai
36
+ `);
37
+
38
+ // Register all command modules
39
+ require('./commands/login').register(program);
40
+ require('./commands/logout').register(program);
41
+ require('./commands/whoami').register(program);
42
+ require('./commands/status').register(program);
43
+ require('./commands/policy').register(program);
44
+ require('./commands/users').register(program);
45
+ require('./commands/user-groups').register(program);
46
+ require('./commands/tools').register(program);
47
+ require('./commands/setup').register(program);
48
+
49
+ // config command for managing CLI settings
50
+ const configCmd = program
51
+ .command('config')
52
+ .description('Manage CLI configuration. Set or view the API base URL and other settings.');
53
+
54
+ configCmd
55
+ .command('set-url <url>')
56
+ .description('Set the API base URL. Use http://localhost:8000 for local development.')
57
+ .addHelpText('after', `
58
+ Examples:
59
+ $ unbound config set-url http://localhost:8000 # Local development
60
+ $ unbound config set-url https://backend.getunbound.ai # Production (default)
61
+ `)
62
+ .action((url) => {
63
+ config.setBaseUrl(url);
64
+ output.success(`API base URL set to ${url}`);
65
+ });
66
+
67
+ configCmd
68
+ .command('get-url')
69
+ .description('Show the current API base URL.')
70
+ .action(() => {
71
+ console.log(config.getBaseUrl());
72
+ });
73
+
74
+ configCmd
75
+ .command('reset-url')
76
+ .description('Reset the API base URL to the default (https://backend.getunbound.ai).')
77
+ .action(() => {
78
+ const cfg = config.readConfig();
79
+ delete cfg.base_url;
80
+ config.writeConfig(cfg);
81
+ output.success(`API base URL reset to ${config.getBaseUrl()}`);
82
+ });
83
+
84
+ configCmd
85
+ .command('set-frontend-url <url>')
86
+ .description('Set the frontend URL. Use http://localhost:3000 for local development.')
87
+ .addHelpText('after', `
88
+ Examples:
89
+ $ unbound config set-frontend-url http://localhost:3000 # Local development
90
+ $ unbound config set-frontend-url https://gateway.getunbound.ai # Production (default)
91
+ `)
92
+ .action((url) => {
93
+ config.setFrontendUrl(url);
94
+ output.success(`Frontend URL set to ${url}`);
95
+ });
96
+
97
+ configCmd
98
+ .command('get-frontend-url')
99
+ .description('Show the current frontend URL.')
100
+ .action(() => {
101
+ console.log(config.getFrontendUrl());
102
+ });
103
+
104
+ configCmd
105
+ .command('reset-frontend-url')
106
+ .description('Reset the frontend URL to the default (https://gateway.getunbound.ai).')
107
+ .action(() => {
108
+ const cfg = config.readConfig();
109
+ delete cfg.frontend_url;
110
+ config.writeConfig(cfg);
111
+ output.success(`Frontend URL reset to ${config.getFrontendUrl()}`);
112
+ });
113
+
114
+ configCmd
115
+ .command('show')
116
+ .description('Show all current configuration values.')
117
+ .action(() => {
118
+ const cfg = config.readConfig();
119
+ output.keyValue([
120
+ ['Config file', config.CONFIG_FILE],
121
+ ['API base URL', config.getBaseUrl()],
122
+ ['Frontend URL', config.getFrontendUrl()],
123
+ ['Logged in', config.isLoggedIn() ? 'Yes' : 'No'],
124
+ ['Email', cfg.email || '-'],
125
+ ['Organization', cfg.org_name || '-'],
126
+ ]);
127
+ });
128
+
129
+ program.parse(process.argv);
package/src/output.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Output formatting utilities for the Unbound CLI.
3
+ * Provides table, JSON, and key-value output for terminal display.
4
+ */
5
+
6
+ function table(rows, columns) {
7
+ if (!rows || rows.length === 0) {
8
+ console.log('No results found.');
9
+ return;
10
+ }
11
+
12
+ // Calculate column widths
13
+ const widths = {};
14
+ for (const col of columns) {
15
+ widths[col.key] = col.header.length;
16
+ }
17
+ for (const row of rows) {
18
+ for (const col of columns) {
19
+ const val = String(col.format ? col.format(row[col.key], row) : row[col.key] ?? '');
20
+ widths[col.key] = Math.max(widths[col.key], val.length);
21
+ }
22
+ }
23
+
24
+ // Header
25
+ const header = columns.map((col) => col.header.padEnd(widths[col.key])).join(' ');
26
+ const separator = columns.map((col) => '-'.repeat(widths[col.key])).join(' ');
27
+ console.log(header);
28
+ console.log(separator);
29
+
30
+ // Rows
31
+ for (const row of rows) {
32
+ const line = columns
33
+ .map((col) => {
34
+ const val = String(col.format ? col.format(row[col.key], row) : row[col.key] ?? '');
35
+ return val.padEnd(widths[col.key]);
36
+ })
37
+ .join(' ');
38
+ console.log(line);
39
+ }
40
+ }
41
+
42
+ function json(data) {
43
+ console.log(JSON.stringify(data, null, 2));
44
+ }
45
+
46
+ function keyValue(pairs) {
47
+ const maxKey = Math.max(...pairs.map(([k]) => k.length));
48
+ for (const [key, value] of pairs) {
49
+ console.log(`${key.padEnd(maxKey)} ${value ?? '-'}`);
50
+ }
51
+ }
52
+
53
+ function success(msg) {
54
+ console.log(`\u2713 ${msg}`);
55
+ }
56
+
57
+ function error(msg) {
58
+ console.error(`Error: ${msg}`);
59
+ }
60
+
61
+ function warn(msg) {
62
+ console.error(`Warning: ${msg}`);
63
+ }
64
+
65
+ module.exports = { table, json, keyValue, success, error, warn };