unbound-cli 0.7.0 → 0.8.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.
package/LOCAL_DEV.md CHANGED
@@ -28,19 +28,26 @@ unbound --help
28
28
  ## Point to local backend
29
29
 
30
30
  ```bash
31
- # Option 1: Set in config (persists across commands)
32
- node src/index.js config set-url http://localhost:8000
31
+ # Option 1a: Set all three at once (recommended)
32
+ node src/index.js config urls http://localhost:8001 http://localhost:3000 http://localhost:8000
33
+ # Positional order: gateway, frontend, backend
34
+
35
+ # Option 1b: Set them individually (persists across commands)
36
+ node src/index.js config set-backend-url http://localhost:8000
33
37
  node src/index.js config set-frontend-url http://localhost:3000
38
+ node src/index.js config set-gateway-url http://localhost:8001
34
39
 
35
40
  # Option 2: Environment variable (per-command)
36
41
  UNBOUND_API_URL=http://localhost:8000 node src/index.js policy list
42
+ UNBOUND_GATEWAY_URL=http://localhost:8001 node src/index.js setup cursor
37
43
 
38
- # Option 3: Set during login
39
- node src/index.js login --base-url http://localhost:8000 --frontend-url http://localhost:3000
44
+ # Option 3: Set during login (also persists to config)
45
+ node src/index.js login --base-url http://localhost:8000 --frontend-url http://localhost:3000 --gateway-url http://localhost:8001
40
46
 
41
- # Reset to production
47
+ # Reset to production (hidden helpers, kept for dev workflow)
42
48
  node src/index.js config reset-url
43
49
  node src/index.js config reset-frontend-url
50
+ node src/index.js config reset-gateway-url
44
51
  ```
45
52
 
46
53
  ## Point setup scripts to a local backend / frontend
@@ -49,8 +56,9 @@ The `setup`, `setup mdm`, `onboard`, and `onboard-mdm` commands invoke Python se
49
56
 
50
57
  - Ping `https://backend.getunbound.ai/api/v1/setup/complete/` when a tool is configured (override with `--backend-url`).
51
58
  - Use the frontend URL for the browser-auth callback if `--api-key` is not already stored (override with `--frontend-url`, passed through to the scripts as `--domain`).
59
+ - Wire AI tools at the gateway URL `https://api.getunbound.ai` (override with `--gateway-url`). The CLI always passes the resolved value (override → config → default) so tools always end up pointed at the right tenant host.
52
60
 
53
- Both flags are hidden from `--help` (dev-only) and work on every setup-invoking command:
61
+ All three flags are hidden from `--help` (dev-only) and work on every setup-invoking command:
54
62
 
55
63
  ```bash
56
64
  # Single tool — override backend only, or both
@@ -86,7 +94,7 @@ When omitted, scripts default to `https://backend.getunbound.ai` and the stored
86
94
  Notes:
87
95
  - `--backend-url` / `--frontend-url` only affect the setup scripts. They do not change the CLI's own API calls (use `config set-url` / `UNBOUND_API_URL` / `config set-frontend-url` / `UNBOUND_FRONTEND_URL` for that).
88
96
  - `onboard` and `onboard-mdm` also take a visible `--domain <url>` flag — that one is for the **discovery** backend (a separate repo), not the setup scripts' frontend. The two flags don't conflict.
89
- - MDM setup scripts (`setup mdm`, `onboard-mdm`) don't do browser auth, so `--frontend-url` is a no-op there; only `--backend-url` is meaningful.
97
+ - MDM setup scripts (`setup mdm`, `onboard-mdm`) don't do browser auth, so `--frontend-url` is dropped by the CLI; only `--backend-url` and `--gateway-url` are forwarded.
90
98
 
91
99
  ## Verify config
92
100
 
@@ -94,6 +102,7 @@ Notes:
94
102
  node src/index.js config show
95
103
  node src/index.js config get-url
96
104
  node src/index.js config get-frontend-url
105
+ node src/index.js config get-gateway-url
97
106
  ```
98
107
 
99
108
  ## Test login flow
@@ -190,26 +199,29 @@ npm unlink -g unbound-cli
190
199
  ### Switching environments
191
200
 
192
201
  ```bash
193
- # Use local backend and frontend
194
- unbound config set-url http://localhost:8000
202
+ # Use local backend, frontend, and gateway
203
+ unbound config set-backend-url http://localhost:8000
195
204
  unbound config set-frontend-url http://localhost:3000
205
+ unbound config set-gateway-url http://localhost:8001
196
206
 
197
207
  # Reset to production
198
208
  unbound config reset-url
199
209
  unbound config reset-frontend-url
210
+ unbound config reset-gateway-url
200
211
 
201
212
  # One-time override via env vars
202
- UNBOUND_API_URL=http://localhost:8000 UNBOUND_FRONTEND_URL=http://localhost:3000 unbound login
213
+ UNBOUND_API_URL=http://localhost:8000 UNBOUND_FRONTEND_URL=http://localhost:3000 UNBOUND_GATEWAY_URL=http://localhost:8001 unbound login
203
214
  ```
204
215
 
205
216
  ### Configuration
206
217
 
207
218
  | Command | Description |
208
219
  |---------|-------------|
209
- | `unbound config set-url <url>` | Set API base URL |
210
- | `unbound config get-url` | Show current API base URL |
211
- | `unbound config reset-url` | Reset to default URL |
212
- | `unbound config set-frontend-url <url>` | Set frontend URL |
213
- | `unbound config get-frontend-url` | Show current frontend URL |
214
- | `unbound config reset-frontend-url` | Reset to default frontend URL |
215
- | `unbound config show` | Show all config values |
220
+ | `unbound config set-backend-url <url>` | Set backend REST API URL (visible) |
221
+ | `unbound config set-frontend-url <url>` | Set frontend URL (visible) |
222
+ | `unbound config set-gateway-url <url>` | Set AI gateway URL (visible) |
223
+ | `unbound config set-url <url>` | Hidden alias for set-backend-url |
224
+ | `unbound config get-url` / `reset-url` | Hidden read/clear for backend URL |
225
+ | `unbound config get-frontend-url` / `reset-frontend-url` | Hidden read/clear for frontend URL |
226
+ | `unbound config get-gateway-url` / `reset-gateway-url` | Hidden read/clear for gateway URL |
227
+ | `unbound config show` | Show all config values |
package/README.md CHANGED
@@ -114,7 +114,7 @@ Available tools: `cursor`, `claude-code-subscription`, `claude-code-gateway`, `g
114
114
 
115
115
  ### MDM AI Tools Discovery
116
116
 
117
- Scan a device for installed AI coding tools and report findings to Unbound. Uses a separate discovery-specific API key. `--domain` defaults to `https://backend.getunbound.ai`.
117
+ Scan a device for installed AI coding tools and report findings to Unbound. Uses a separate discovery-specific API key. `--domain` defaults to the configured backend URL (or `https://backend.getunbound.ai` when unset).
118
118
 
119
119
  | Command | Description |
120
120
  |---------|-------------|
@@ -246,11 +246,29 @@ Supported tool types: `CLAUDE_CODE`, `UNBOUND_CLAUDE_CODE`, `CURSOR`, `COPILOT`,
246
246
 
247
247
  ## Configuration
248
248
 
249
- Config is stored in `~/.unbound/config.json`.
249
+ Config is stored in `~/.unbound/config.json`. For tenant deployments, point the CLI at your own hosts in one command (recommended for new installs):
250
+
251
+ ```bash
252
+ unbound config urls <gateway-url> <frontend-url> <backend-url>
253
+ # example
254
+ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
255
+ unbound login --api-key <YOUR_API_KEY>
256
+ ```
257
+
258
+ Or set them one at a time when changing a single host:
259
+
260
+ ```bash
261
+ unbound config set-gateway-url https://api.acme.com # AI gateway host (used by tool setup)
262
+ unbound config set-frontend-url https://gateway.acme.com # Browser login host
263
+ unbound config set-backend-url https://backend.acme.com # REST API host
264
+ unbound config show
265
+ ```
266
+
267
+ Bare hostnames are accepted (`backend.acme.com` becomes `https://backend.acme.com`). Each URL also accepts a per-process override via env var.
250
268
 
251
269
  **Backend URL priority** (highest to lowest):
252
270
  1. `UNBOUND_API_URL` environment variable
253
- 2. `base_url` in `~/.unbound/config.json` (set via `unbound config set-url`)
271
+ 2. `base_url` in `~/.unbound/config.json` (set via `unbound config set-backend-url`)
254
272
  3. Default: `https://backend.getunbound.ai`
255
273
 
256
274
  **Frontend URL priority** (highest to lowest):
@@ -258,6 +276,11 @@ Config is stored in `~/.unbound/config.json`.
258
276
  2. `frontend_url` in `~/.unbound/config.json` (set via `unbound config set-frontend-url`)
259
277
  3. Default: `https://gateway.getunbound.ai`
260
278
 
279
+ **Gateway URL priority** (highest to lowest):
280
+ 1. `UNBOUND_GATEWAY_URL` environment variable
281
+ 2. `gateway_url` in `~/.unbound/config.json` (set via `unbound config set-gateway-url`)
282
+ 3. Default: `https://api.getunbound.ai`
283
+
261
284
  ## Global Options
262
285
 
263
286
  All list/get commands support `--json` for machine-readable JSON output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,10 +3,10 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const https = require('https');
6
+ const config = require('../config');
6
7
  const output = require('../output');
7
8
 
8
9
  const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
9
- const DEFAULT_DOMAIN = 'https://backend.getunbound.ai';
10
10
  const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
11
11
 
12
12
  // Native Windows (cmd/PowerShell) takes the install.ps1 path below. WSL reports
@@ -144,10 +144,11 @@ async function runDiscoveryScriptWindows(scriptName, args) {
144
144
  * Emits a warning if not running as root (scan will be limited to the current user).
145
145
  * Throws on failure. Used by both the `discover` command and the `onboard` command.
146
146
  */
147
- async function runDiscoveryScan({ apiKey, domain = DEFAULT_DOMAIN }) {
147
+ async function runDiscoveryScan({ apiKey, domain }) {
148
148
  if (!apiKey) {
149
149
  throw new Error('Discovery API key is required.');
150
150
  }
151
+ if (!domain) domain = config.getBaseUrl();
151
152
 
152
153
  if (!isRoot()) {
153
154
  output.warn('Running without root. Only scanning current user\'s tools.');
@@ -167,14 +168,15 @@ function register(program) {
167
168
  'and report findings to Unbound.'
168
169
  )
169
170
  .option('--api-key <key>', 'Discovery API key (required)')
170
- .option('--domain <url>', 'Backend URL', DEFAULT_DOMAIN)
171
+ .option('--domain <url>', 'Backend URL (defaults to configured backend)')
171
172
  .addHelpText('after', `
172
173
  Scans this device for installed AI coding tools (Cursor, Claude Code,
173
174
  Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
174
175
  and more) and reports findings to the Unbound backend.
175
176
 
176
177
  The --api-key is a discovery-specific key (separate from login credentials).
177
- The --domain defaults to https://backend.getunbound.ai.
178
+ The --domain defaults to the backend URL configured via "unbound config set-backend-url"
179
+ (falls back to https://backend.getunbound.ai when unset).
178
180
 
179
181
  Run as root (sudo) to scan all users on the device.
180
182
  Run without root to scan the current user only.
@@ -211,7 +213,7 @@ Examples:
211
213
  .command('schedule')
212
214
  .description('Set up a recurring 12-hour discovery scan via macOS LaunchAgent.')
213
215
  .option('--api-key <key>', 'Discovery API key (required)')
214
- .option('--domain <url>', 'Backend URL', DEFAULT_DOMAIN)
216
+ .option('--domain <url>', 'Backend URL (defaults to configured backend)')
215
217
  .addHelpText('after', `
216
218
  Creates a macOS LaunchAgent that runs the discovery scan every 12 hours.
217
219
  Credentials are stored securely in the macOS Keychain (not in files).
@@ -241,7 +243,8 @@ Examples:
241
243
  return;
242
244
  }
243
245
 
244
- const args = `--api-key ${shellEscape(opts.apiKey)} --domain ${shellEscape(opts.domain)}`;
246
+ const domain = opts.domain || config.getBaseUrl();
247
+ const args = `--api-key ${shellEscape(opts.apiKey)} --domain ${shellEscape(domain)}`;
245
248
  await runDiscoveryScript('setup-scheduled-scan.sh', args);
246
249
  } catch (err) {
247
250
  output.error(err.message);
@@ -9,7 +9,9 @@ function register(program) {
9
9
  .command('login')
10
10
  .description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
11
11
  .option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
12
- .addOption(new Option('--base-url <url>', 'Set a custom API base URL').hideHelp())
12
+ .addOption(new Option('--base-url <url>', 'Set a custom backend URL (also persists to config)').hideHelp())
13
+ .addOption(new Option('--frontend-url <url>', 'Set a custom frontend URL (also persists to config)').hideHelp())
14
+ .addOption(new Option('--gateway-url <url>', 'Set a custom gateway URL (also persists to config)').hideHelp())
13
15
  .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
14
16
  .addHelpText('after', `
15
17
  Authentication methods:
@@ -25,16 +27,23 @@ Authentication methods:
25
27
  Options:
26
28
  --domain sets a custom domain for organizations with self-hosted frontends.
27
29
 
30
+ Tenant deployments:
31
+ Before logging in to a non-default Unbound tenant, set all three URLs:
32
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
33
+ $ unbound login --api-key <YOUR_API_KEY>
34
+
28
35
  Examples:
29
36
  $ unbound login # Login via default gateway
30
37
  $ unbound login --domain custom.example.com # Login via custom domain
31
38
  $ unbound login --api-key sk-abc123 # Non-interactive login
39
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com \\
40
+ && unbound login --api-key sk-abc123 # Tenant onboarding
32
41
  `)
33
42
  .action(async (opts) => {
34
43
  try {
35
- if (opts.baseUrl) {
36
- config.setBaseUrl(opts.baseUrl);
37
- }
44
+ if (opts.baseUrl) config.setBaseUrl(opts.baseUrl);
45
+ if (opts.frontendUrl) config.setFrontendUrl(opts.frontendUrl);
46
+ if (opts.gatewayUrl) config.setGatewayUrl(opts.gatewayUrl);
38
47
 
39
48
  let apiKey;
40
49
 
@@ -5,15 +5,14 @@ const { ensureLoggedIn } = require('../auth');
5
5
  const { runSetupAllBundle, runMdmSetupAllBundle, checkRoot, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
6
6
  const { runDiscoveryScan } = require('./discover');
7
7
 
8
- const DEFAULT_DOMAIN = 'https://backend.getunbound.ai';
9
-
10
8
  /**
11
9
  * Builds the recovery-command suffix for partial-failure hints.
12
- * Includes `--domain <value>` only when the user supplied a non-default domain,
13
- * so users running against a custom backend get a copy-pastable command.
10
+ * Includes `--domain <value>` only when the resolved discovery backend
11
+ * differs from the global default, so users running against a custom
12
+ * backend get a copy-pastable command.
14
13
  */
15
14
  function domainHintSuffix(domain) {
16
- if (!domain || domain === DEFAULT_DOMAIN) return '';
15
+ if (!domain || domain === config.DEFAULT_BASE_URL) return '';
17
16
  return ` --domain ${domain}`;
18
17
  }
19
18
 
@@ -26,9 +25,10 @@ function register(program) {
26
25
  )
27
26
  .requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
28
27
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
29
- .option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
28
+ .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
30
29
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
31
30
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
31
+ .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
32
32
  .addHelpText('after', `
33
33
  Runs the full onboarding flow for an end user:
34
34
  1. Logs in with --api-key and stores credentials.
@@ -50,27 +50,31 @@ Examples:
50
50
  `)
51
51
  .action(async (opts) => {
52
52
  let setupSucceeded = false;
53
+ const backendUrl = opts.backendUrl || config.getBaseUrl();
54
+ const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
55
+ const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
56
+ const discoveryDomain = opts.domain || backendUrl;
53
57
  try {
54
58
  await ensureLoggedIn({ apiKey: opts.apiKey });
55
59
  const apiKey = config.getApiKey();
56
60
 
57
61
  console.log('');
58
62
  output.info('Step 1/2: Installing tool bundle');
59
- const ok = await runSetupAllBundle(apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
63
+ const ok = await runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl });
60
64
  if (!ok) return;
61
65
  setupSucceeded = true;
62
66
 
63
67
  console.log('');
64
68
  output.info('Step 2/2: Running device discovery');
65
69
  console.log('');
66
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: opts.domain });
70
+ await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
67
71
 
68
72
  console.log('');
69
73
  output.success('Onboarding complete');
70
74
  } catch (err) {
71
75
  if (!err.displayed) output.error(err.message);
72
76
  if (setupSucceeded) {
73
- const suffix = domainHintSuffix(opts.domain);
77
+ const suffix = domainHintSuffix(discoveryDomain);
74
78
  console.error(' Tool setup completed successfully — only discovery failed.');
75
79
  console.error(` Re-run discovery only with: unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
76
80
  }
@@ -88,9 +92,10 @@ Examples:
88
92
  )
89
93
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
90
94
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
91
- .option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
95
+ .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
92
96
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
93
97
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
98
+ .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
94
99
  .addHelpText('after', `
95
100
  Runs the full MDM onboarding flow for device enrollment:
96
101
  1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
@@ -106,26 +111,29 @@ Examples:
106
111
  `)
107
112
  .action(async (opts) => {
108
113
  let setupSucceeded = false;
114
+ const backendUrl = opts.backendUrl || config.getBaseUrl();
115
+ const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
116
+ const discoveryDomain = opts.domain || backendUrl;
109
117
  try {
110
118
  checkRoot('onboard-mdm');
111
119
 
112
120
  console.log('');
113
121
  output.info('Step 1/2: Installing MDM tool bundle');
114
- const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
122
+ const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl, gatewayUrl });
115
123
  if (!ok) return;
116
124
  setupSucceeded = true;
117
125
 
118
126
  console.log('');
119
127
  output.info('Step 2/2: Running device discovery');
120
128
  console.log('');
121
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: opts.domain });
129
+ await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
122
130
 
123
131
  console.log('');
124
132
  output.success('MDM onboarding complete');
125
133
  } catch (err) {
126
134
  if (!err.displayed) output.error(err.message);
127
135
  if (setupSucceeded) {
128
- const suffix = domainHintSuffix(opts.domain);
136
+ const suffix = domainHintSuffix(discoveryDomain);
129
137
  console.error(' MDM tool setup completed successfully — only discovery failed.');
130
138
  console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
131
139
  }
@@ -207,13 +207,25 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
207
207
  }
208
208
 
209
209
  /**
210
- * Runs a Python setup script from the setup repo with inherited stdio (live output).
210
+ * Builds the shared argv tail for setup.py invocations. Always passes the
211
+ * tenant URL flags so the script's behavior is not sensitive to drift in its
212
+ * own defaults. `mdm: true` skips --domain (frontend URL) since MDM scripts
213
+ * have no browser-auth flow.
211
214
  */
212
- async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
215
+ function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm } = {}) {
213
216
  let args = `--api-key ${shellEscape(apiKey)}`;
214
217
  if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
215
- if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
218
+ if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
219
+ if (!mdm && frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
216
220
  if (clear) args += ' --clear';
221
+ return args;
222
+ }
223
+
224
+ /**
225
+ * Runs a Python setup script from the setup repo with inherited stdio (live output).
226
+ */
227
+ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl } = {}) {
228
+ const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear });
217
229
  console.log('');
218
230
  if (isWindowsNative()) {
219
231
  await runPythonScriptWindows(scriptPath, args, { capture: false });
@@ -308,6 +320,7 @@ function register(program) {
308
320
  .option('--all', 'Set up the default bundle: Cursor, Claude Code (hooks), Codex (hooks)')
309
321
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
310
322
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
323
+ .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
311
324
  .addHelpText('after', `
312
325
  Available tools:
313
326
  cursor Cursor IDE
@@ -356,7 +369,10 @@ automatically to authenticate before proceeding.
356
369
  try {
357
370
  await ensureLoggedIn({ apiKey: opts.apiKey });
358
371
  const apiKey = config.getApiKey();
359
- const frontendUrl = config.getFrontendUrl();
372
+ const backendUrl = opts.backendUrl || config.getBaseUrl();
373
+ const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
374
+ const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
375
+ const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
360
376
 
361
377
  // --all expands to the default bundle. Cannot be combined with explicit tool names.
362
378
  if (opts.all) {
@@ -383,9 +399,7 @@ automatically to authenticate before proceeding.
383
399
  const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
384
400
  console.log('');
385
401
 
386
- let interactiveArgs = `--api-key ${shellEscape(apiKey)}`;
387
- if (opts.backendUrl) interactiveArgs += ` --backend-url ${shellEscape(opts.backendUrl)}`;
388
- if (opts.frontendUrl) interactiveArgs += ` --domain ${shellEscape(opts.frontendUrl)}`;
402
+ const interactiveArgs = buildScriptArgs(apiKey, urlOpts);
389
403
  const ok = await runBatch(selectedTools, (tool) =>
390
404
  runScriptPiped(tool.script, interactiveArgs)
391
405
  );
@@ -452,13 +466,13 @@ automatically to authenticate before proceeding.
452
466
  const toolName = tools[0];
453
467
 
454
468
  if (SETUP_TOOL_MAP[toolName]) {
455
- await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
469
+ await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, ...urlOpts });
456
470
  } else if (MODE_TOOLS[toolName]) {
457
471
  const mode = MODE_TOOLS[toolName];
458
472
  if (opts.clear) {
459
473
  // Clear both modes
460
- await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
461
- await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
474
+ await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, ...urlOpts });
475
+ await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, ...urlOpts });
462
476
  } else {
463
477
  let useSubscription = opts.subscription;
464
478
  if (!opts.subscription && !opts.gateway) {
@@ -466,7 +480,7 @@ automatically to authenticate before proceeding.
466
480
  useSubscription = choice === 'subscription';
467
481
  }
468
482
  const resolved = useSubscription ? mode.subscription : mode.gateway;
469
- await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
483
+ await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, urlOpts);
470
484
  }
471
485
  } else if (INSTRUCTION_TOOLS[toolName]) {
472
486
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
@@ -504,10 +518,7 @@ automatically to authenticate before proceeding.
504
518
  // Run automated tools with spinners
505
519
  if (resolvedScripts.length > 0) {
506
520
  console.log('');
507
- let args = `--api-key ${shellEscape(apiKey)}`;
508
- if (opts.backendUrl) args += ` --backend-url ${shellEscape(opts.backendUrl)}`;
509
- if (opts.frontendUrl) args += ` --domain ${shellEscape(opts.frontendUrl)}`;
510
- if (opts.clear) args += ' --clear';
521
+ const args = buildScriptArgs(apiKey, { ...urlOpts, clear: opts.clear });
511
522
  const ok = await runBatch(resolvedScripts, (tool) =>
512
523
  runScriptPiped(tool.script, args)
513
524
  , { clear: opts.clear });
@@ -569,9 +580,11 @@ Examples:
569
580
  try {
570
581
  checkRoot();
571
582
  // --all and --clear are defined on both this command and the parent `setup` command;
572
- // --backend-url and --frontend-url are defined only on the parent `setup` command.
573
- // Use optsWithGlobals() so all four work regardless of position relative to `mdm`.
583
+ // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
584
+ // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
574
585
  const globalOpts = command.optsWithGlobals();
586
+ const backendUrl = globalOpts.backendUrl || config.getBaseUrl();
587
+ const gatewayUrl = globalOpts.gatewayUrl || config.getGatewayUrl();
575
588
 
576
589
  if (globalOpts.all && tools.length > 0) {
577
590
  output.error('Cannot combine --all with specific tool names. Use one or the other.');
@@ -614,17 +627,16 @@ Examples:
614
627
  const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
615
628
  console.log('');
616
629
 
617
- const mdmArgs = (tool) => {
618
- let args = `--api-key ${shellEscape(opts.adminApiKey)}`;
619
- if (globalOpts.backendUrl) args += ` --backend-url ${shellEscape(globalOpts.backendUrl)}`;
620
- if (globalOpts.frontendUrl) args += ` --domain ${shellEscape(globalOpts.frontendUrl)}`;
621
- if (globalOpts.clear) args += ' --clear';
622
- return args;
623
- };
630
+ const args = buildScriptArgs(opts.adminApiKey, {
631
+ backendUrl,
632
+ gatewayUrl,
633
+ clear: globalOpts.clear,
634
+ mdm: true,
635
+ });
624
636
 
625
637
  const ok = await runBatch(
626
638
  resolvedTools,
627
- (tool) => runScriptPiped(tool.script, mdmArgs(tool)),
639
+ (tool) => runScriptPiped(tool.script, args),
628
640
  { clear: globalOpts.clear }
629
641
  );
630
642
  if (!ok) return;
@@ -643,11 +655,9 @@ Examples:
643
655
  * Assumes the caller has already ensured the user is logged in.
644
656
  * Returns true on success, false on failure.
645
657
  */
646
- async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
658
+ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl } = {}) {
647
659
  const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
648
- let args = `--api-key ${shellEscape(apiKey)}`;
649
- if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
650
- if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
660
+ const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl });
651
661
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
652
662
  }
653
663
 
@@ -656,11 +666,9 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
656
666
  * Caller must ensure the process is running as root.
657
667
  * Returns true on success, false on failure.
658
668
  */
659
- async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl } = {}) {
669
+ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl } = {}) {
660
670
  const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
661
- let args = `--api-key ${shellEscape(adminApiKey)}`;
662
- if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
663
- if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
671
+ const args = buildScriptArgs(adminApiKey, { backendUrl, gatewayUrl, mdm: true });
664
672
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
665
673
  }
666
674
 
@@ -12,7 +12,9 @@ Output fields:
12
12
  Logged in - Whether credentials are stored (Yes/No)
13
13
  Email - The authenticated user's email (if logged in)
14
14
  Organization - The organization name (if logged in)
15
- Unbound Gateway - The Unbound gateway URL (if logged in)
15
+ Backend URL - REST API host (configurable for tenant deployments)
16
+ Frontend URL - Browser login host
17
+ Gateway URL - AI gateway host (used by tool setup)
16
18
  API status - Connectivity check result (Connected / Error)
17
19
 
18
20
  Examples:
@@ -46,8 +48,10 @@ Examples:
46
48
  const cfg = config.readConfig();
47
49
  pairs.push(['Email', cfg.email || '-']);
48
50
  pairs.push(['Organization', cfg.org_name || '-']);
49
- pairs.push(['Unbound Gateway', config.getFrontendUrl()]);
50
51
  }
52
+ pairs.push(['Backend URL', config.getBaseUrl()]);
53
+ pairs.push(['Frontend URL', config.getFrontendUrl()]);
54
+ pairs.push(['Gateway URL', config.getGatewayUrl()]);
51
55
  pairs.push(['API status', connectivity]);
52
56
 
53
57
  if (opts.json) {
@@ -57,7 +61,9 @@ Examples:
57
61
  logged_in: loggedIn,
58
62
  email: loggedIn ? (cfg.email || null) : null,
59
63
  organization: loggedIn ? (cfg.org_name || null) : null,
60
- gateway_url: loggedIn ? config.getFrontendUrl() : null,
64
+ backend_url: config.getBaseUrl(),
65
+ frontend_url: config.getFrontendUrl(),
66
+ gateway_url: config.getGatewayUrl(),
61
67
  api_status: connectivity,
62
68
  });
63
69
  return;
package/src/config.js CHANGED
@@ -6,6 +6,36 @@ const CONFIG_DIR = path.join(os.homedir(), '.unbound');
6
6
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
7
7
  const DEFAULT_BASE_URL = 'https://backend.getunbound.ai';
8
8
  const DEFAULT_FRONTEND_URL = 'https://gateway.getunbound.ai';
9
+ const DEFAULT_GATEWAY_URL = 'https://api.getunbound.ai';
10
+
11
+ function normalizeUrl(input) {
12
+ if (input === undefined || input === null) {
13
+ throw new Error('URL is required');
14
+ }
15
+ let value = String(input).trim();
16
+ if (!value) {
17
+ throw new Error('URL must not be empty');
18
+ }
19
+ if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(value)) {
20
+ value = `https://${value}`;
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = new URL(value);
25
+ } catch {
26
+ throw new Error(`Invalid URL: ${input}`);
27
+ }
28
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
29
+ throw new Error(`URL must use http or https: ${input}`);
30
+ }
31
+ if (!parsed.hostname) {
32
+ throw new Error(`URL must include a hostname: ${input}`);
33
+ }
34
+ // Tenant URLs are origin-only (scheme + host[:port]). Paths, queries, fragments,
35
+ // and embedded credentials are stripped — they break downstream string concat
36
+ // (e.g., `${frontend_url}/automations/api-key-callback`) and have no use here.
37
+ return `${parsed.protocol}//${parsed.host}`;
38
+ }
9
39
 
10
40
  function ensureConfigDir() {
11
41
  if (!fs.existsSync(CONFIG_DIR)) {
@@ -46,8 +76,9 @@ function getBaseUrl() {
46
76
 
47
77
  function setBaseUrl(url) {
48
78
  const config = readConfig();
49
- config.base_url = url;
79
+ config.base_url = normalizeUrl(url);
50
80
  writeConfig(config);
81
+ return config.base_url;
51
82
  }
52
83
 
53
84
  function getFrontendUrl() {
@@ -56,8 +87,32 @@ function getFrontendUrl() {
56
87
 
57
88
  function setFrontendUrl(url) {
58
89
  const config = readConfig();
59
- config.frontend_url = url;
90
+ config.frontend_url = normalizeUrl(url);
91
+ writeConfig(config);
92
+ return config.frontend_url;
93
+ }
94
+
95
+ function getGatewayUrl() {
96
+ return process.env.UNBOUND_GATEWAY_URL || readConfig().gateway_url || DEFAULT_GATEWAY_URL;
97
+ }
98
+
99
+ function setGatewayUrl(url) {
100
+ const config = readConfig();
101
+ config.gateway_url = normalizeUrl(url);
102
+ writeConfig(config);
103
+ return config.gateway_url;
104
+ }
105
+
106
+ function setUrls({ gateway, frontend, backend }) {
107
+ const normalized = {
108
+ gateway_url: normalizeUrl(gateway),
109
+ frontend_url: normalizeUrl(frontend),
110
+ base_url: normalizeUrl(backend),
111
+ };
112
+ const config = readConfig();
113
+ Object.assign(config, normalized);
60
114
  writeConfig(config);
115
+ return normalized;
61
116
  }
62
117
 
63
118
  function clearConfig() {
@@ -88,6 +143,10 @@ function backfillUserInfo(apiResponse) {
88
143
  module.exports = {
89
144
  CONFIG_DIR,
90
145
  CONFIG_FILE,
146
+ DEFAULT_BASE_URL,
147
+ DEFAULT_FRONTEND_URL,
148
+ DEFAULT_GATEWAY_URL,
149
+ normalizeUrl,
91
150
  ensureConfigDir,
92
151
  readConfig,
93
152
  writeConfig,
@@ -97,6 +156,9 @@ module.exports = {
97
156
  setBaseUrl,
98
157
  getFrontendUrl,
99
158
  setFrontendUrl,
159
+ getGatewayUrl,
160
+ setGatewayUrl,
161
+ setUrls,
100
162
  clearConfig,
101
163
  isLoggedIn,
102
164
  backfillUserInfo,
package/src/index.js CHANGED
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Force UTF-8 stdio in every subprocess the CLI spawns (directly or
4
+ // transitively through bash / PowerShell / Python). Windows Python defaults
5
+ // to cp1252, which UnicodeEncodeError's on the ✅/❌ prints in setup scripts
6
+ // and aborts onboard mid-flight. Setting on process.env applies universally —
7
+ // Node's child_process APIs inherit process.env by default, so every current
8
+ // and future command is covered without touching call sites.
9
+ process.env.PYTHONIOENCODING = process.env.PYTHONIOENCODING || 'utf-8';
10
+ process.env.PYTHONUTF8 = process.env.PYTHONUTF8 || '1';
11
+
3
12
  const { Command } = require('commander');
4
13
  const config = require('./config');
5
14
  const output = require('./output');
@@ -23,6 +32,13 @@ AUTHENTICATION
23
32
  $ unbound whoami Show current user and organization
24
33
  $ unbound status Show CLI status and API connectivity
25
34
 
35
+ Tenant deployments — set all three URLs once, then log in with an API key:
36
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
37
+ $ unbound login --api-key <YOUR_API_KEY>
38
+ Example:
39
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
40
+ $ unbound login --api-key sk-...
41
+
26
42
  ONBOARDING (one-step install + discover)
27
43
  $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
28
44
  $ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
@@ -64,7 +80,7 @@ MDM SETUP (admin, requires root)
64
80
  $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
65
81
 
66
82
  MDM AI TOOLS DISCOVERY
67
- --domain defaults to https://backend.getunbound.ai
83
+ --domain defaults to the configured backend URL (set via "unbound config set-backend-url")
68
84
  $ sudo unbound discover --api-key KEY Scan all users (requires root)
69
85
  $ unbound discover --api-key KEY Scan current user only
70
86
  $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
@@ -124,7 +140,20 @@ CHAT (Admin/Manager)
124
140
  See "unbound chat --help" for the response shape and REPL slash commands.
125
141
 
126
142
  CONFIGURATION
127
- $ unbound config show Show all settings
143
+ Defaults point to api/gateway/backend.getunbound.ai. Tenant deployments override them.
144
+
145
+ One-shot setup (recommended for new tenants):
146
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
147
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
148
+
149
+ Per-URL setters (change one host at a time):
150
+ $ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
151
+ $ unbound config set-frontend-url <url> Frontend host (browser login flow)
152
+ $ unbound config set-backend-url <url> Backend REST API host
153
+
154
+ View:
155
+ $ unbound config show All URLs + login state
156
+ $ unbound config show --json Machine-readable
128
157
 
129
158
  FILES
130
159
  ~/.unbound/config.json Credentials and CLI settings
@@ -151,52 +180,127 @@ require('./commands/chat').register(program);
151
180
  // config command for managing CLI settings
152
181
  const configCmd = program
153
182
  .command('config')
154
- .description('Manage CLI configuration settings.');
183
+ .description('Manage CLI configuration settings (tenant URLs, view current state).')
184
+ .addHelpText('after', `
185
+ Tenant onboarding (one-shot — recommended):
186
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
187
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
188
+
189
+ Per-URL setters (use when changing one host at a time):
190
+ $ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
191
+ $ unbound config set-frontend-url <url> Browser login host
192
+ $ unbound config set-backend-url <url> REST API host
193
+
194
+ View:
195
+ $ unbound config show Print all values, including defaults
196
+ $ unbound config show --json Same, machine-readable
197
+
198
+ Notes:
199
+ - Bare hostnames are accepted (e.g. "api.acme.com" → "https://api.acme.com").
200
+ - Settings persist in ~/.unbound/config.json. Defaults: api/gateway/backend.getunbound.ai.
201
+ - Run "unbound login --api-key <key>" after configuring URLs (no browser needed).
202
+ `);
155
203
 
156
- // Internal/dev commands hidden from help but still functional
204
+ // Reports the value that was actually written to ~/.unbound/config.json,
205
+ // not what the getter would resolve to. The getter applies env-var precedence
206
+ // (UNBOUND_*_URL), which would mislead the user if they had an override set —
207
+ // the message would echo the env var instead of the value they just typed.
208
+ function safeSet(label, fn, url) {
209
+ try {
210
+ const written = fn(url);
211
+ output.success(`${label} set to ${written}`);
212
+ } catch (err) {
213
+ output.error(err.message);
214
+ process.exitCode = 1;
215
+ }
216
+ }
217
+
218
+ // Visible: one-shot setter for all three tenant URLs.
219
+ // Positional order: gateway, frontend, backend — same order as the help text and docs.
157
220
  configCmd
158
- .command('set-url <url>', { hidden: true })
159
- .description('Set the API base URL (internal)')
160
- .action((url) => {
161
- config.setBaseUrl(url);
162
- output.success(`API base URL set to ${url}`);
221
+ .command('urls <gateway-url> <frontend-url> <backend-url>')
222
+ .description('Set all three tenant URLs at once (gateway, frontend, backend).')
223
+ .allowExcessArguments(false)
224
+ .addHelpText('after', `
225
+ Use this on a fresh install for tenant deployments. Positional order is fixed:
226
+ 1. <gateway-url> — AI gateway host (e.g. https://api.acme.com)
227
+ Wired into tool setup scripts (ANTHROPIC_BASE_URL, codex config, etc.)
228
+ 2. <frontend-url> — Frontend host (e.g. https://gateway.acme.com)
229
+ Used by the browser login flow.
230
+ 3. <backend-url> — REST API host (e.g. https://backend.acme.com)
231
+ All "unbound *" commands (whoami, status, policies, ...) hit this.
232
+
233
+ Bare hostnames are accepted; "https://" is added automatically.
234
+ The three values are written atomically to ~/.unbound/config.json.
235
+ After running this, log in with: unbound login --api-key <YOUR_API_KEY>
236
+
237
+ Examples:
238
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
239
+ $ unbound config urls api.acme.com gateway.acme.com backend.acme.com
240
+ $ unbound config show
241
+ `)
242
+ .action((gateway, frontend, backend) => {
243
+ try {
244
+ const written = config.setUrls({ gateway, frontend, backend });
245
+ output.success('All three tenant URLs set:');
246
+ output.keyValue([
247
+ ['Gateway URL', written.gateway_url],
248
+ ['Frontend URL', written.frontend_url],
249
+ ['Backend URL', written.base_url],
250
+ ]);
251
+ console.log('');
252
+ output.info('Next: unbound login --api-key <YOUR_API_KEY>');
253
+ } catch (err) {
254
+ output.error(err.message);
255
+ process.exitCode = 1;
256
+ }
163
257
  });
164
258
 
259
+ // Per-URL setters (use when changing a single host).
260
+ configCmd
261
+ .command('set-backend-url <url>')
262
+ .description('Set the backend REST API URL (tenant deployments).')
263
+ .action((url) => safeSet('Backend URL', config.setBaseUrl, url));
264
+
265
+ configCmd
266
+ .command('set-frontend-url <url>')
267
+ .description('Set the frontend URL used by the browser login flow.')
268
+ .action((url) => safeSet('Frontend URL', config.setFrontendUrl, url));
269
+
270
+ configCmd
271
+ .command('set-gateway-url <url>')
272
+ .description('Set the AI gateway URL (used by tool setup scripts).')
273
+ .action((url) => safeSet('Gateway URL', config.setGatewayUrl, url));
274
+
275
+ // Hidden back-compat aliases — keep working for existing scripts/users.
276
+ configCmd
277
+ .command('set-url <url>', { hidden: true })
278
+ .description('Alias of set-backend-url (kept for backward compatibility).')
279
+ .action((url) => safeSet('Backend URL', config.setBaseUrl, url));
280
+
165
281
  configCmd
166
282
  .command('get-url', { hidden: true })
167
- .description('Show the current API base URL (internal)')
168
- .action(() => {
169
- console.log(config.getBaseUrl());
170
- });
283
+ .description('Show the current backend URL (kept for backward compatibility).')
284
+ .action(() => { console.log(config.getBaseUrl()); });
171
285
 
172
286
  configCmd
173
287
  .command('reset-url', { hidden: true })
174
- .description('Reset the API base URL to default (internal)')
288
+ .description('Reset the backend URL to default (kept for backward compatibility).')
175
289
  .action(() => {
176
290
  const cfg = config.readConfig();
177
291
  delete cfg.base_url;
178
292
  config.writeConfig(cfg);
179
- output.success(`API base URL reset to ${config.getBaseUrl()}`);
180
- });
181
-
182
- configCmd
183
- .command('set-frontend-url <url>', { hidden: true })
184
- .description('Set the frontend URL (internal)')
185
- .action((url) => {
186
- config.setFrontendUrl(url);
187
- output.success(`Frontend URL set to ${url}`);
293
+ output.success(`Backend URL reset to ${config.getBaseUrl()}`);
188
294
  });
189
295
 
190
296
  configCmd
191
297
  .command('get-frontend-url', { hidden: true })
192
- .description('Show the current frontend URL (internal)')
193
- .action(() => {
194
- console.log(config.getFrontendUrl());
195
- });
298
+ .description('Show the current frontend URL (kept for backward compatibility).')
299
+ .action(() => { console.log(config.getFrontendUrl()); });
196
300
 
197
301
  configCmd
198
302
  .command('reset-frontend-url', { hidden: true })
199
- .description('Reset the frontend URL to default (internal)')
303
+ .description('Reset the frontend URL to default (kept for backward compatibility).')
200
304
  .action(() => {
201
305
  const cfg = config.readConfig();
202
306
  delete cfg.frontend_url;
@@ -204,6 +308,21 @@ configCmd
204
308
  output.success(`Frontend URL reset to ${config.getFrontendUrl()}`);
205
309
  });
206
310
 
311
+ configCmd
312
+ .command('get-gateway-url', { hidden: true })
313
+ .description('Show the current gateway URL.')
314
+ .action(() => { console.log(config.getGatewayUrl()); });
315
+
316
+ configCmd
317
+ .command('reset-gateway-url', { hidden: true })
318
+ .description('Reset the gateway URL to default.')
319
+ .action(() => {
320
+ const cfg = config.readConfig();
321
+ delete cfg.gateway_url;
322
+ config.writeConfig(cfg);
323
+ output.success(`Gateway URL reset to ${config.getGatewayUrl()}`);
324
+ });
325
+
207
326
  configCmd
208
327
  .command('show')
209
328
  .description('Show all current configuration values.')
@@ -214,7 +333,9 @@ configCmd
214
333
  if (opts.json) {
215
334
  output.json({
216
335
  config_file: config.CONFIG_FILE,
217
- gateway_url: config.getFrontendUrl(),
336
+ backend_url: config.getBaseUrl(),
337
+ frontend_url: config.getFrontendUrl(),
338
+ gateway_url: config.getGatewayUrl(),
218
339
  logged_in: config.isLoggedIn(),
219
340
  email: cfg.email || null,
220
341
  organization: cfg.org_name || null,
@@ -224,7 +345,9 @@ configCmd
224
345
 
225
346
  output.keyValue([
226
347
  ['Config file', config.CONFIG_FILE],
227
- ['Unbound Gateway', config.getFrontendUrl()],
348
+ ['Backend URL', config.getBaseUrl()],
349
+ ['Frontend URL', config.getFrontendUrl()],
350
+ ['Gateway URL', config.getGatewayUrl()],
228
351
  ['Logged in', config.isLoggedIn() ? 'Yes' : 'No'],
229
352
  ['Email', cfg.email || '-'],
230
353
  ['Organization', cfg.org_name || '-'],
@@ -0,0 +1,98 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { normalizeUrl } = require('../src/config');
4
+
5
+ test('normalizeUrl: bare hostname gets https://', () => {
6
+ assert.equal(normalizeUrl('api.acme.com'), 'https://api.acme.com');
7
+ });
8
+
9
+ test('normalizeUrl: trims whitespace', () => {
10
+ assert.equal(normalizeUrl(' api.acme.com '), 'https://api.acme.com');
11
+ });
12
+
13
+ test('normalizeUrl: strips trailing slash', () => {
14
+ assert.equal(normalizeUrl('https://api.acme.com/'), 'https://api.acme.com');
15
+ });
16
+
17
+ test('normalizeUrl: preserves explicit port', () => {
18
+ assert.equal(normalizeUrl('localhost:8000'), 'https://localhost:8000');
19
+ assert.equal(normalizeUrl('http://localhost:8000'), 'http://localhost:8000');
20
+ });
21
+
22
+ test('normalizeUrl: preserves http scheme when explicit', () => {
23
+ assert.equal(normalizeUrl('http://localhost:3000'), 'http://localhost:3000');
24
+ });
25
+
26
+ test('normalizeUrl: strips path so downstream string-concat is safe', () => {
27
+ assert.equal(
28
+ normalizeUrl('https://gateway.acme.com/login?next=/foo'),
29
+ 'https://gateway.acme.com',
30
+ );
31
+ assert.equal(
32
+ normalizeUrl('https://api.acme.com/v1/'),
33
+ 'https://api.acme.com',
34
+ );
35
+ });
36
+
37
+ test('normalizeUrl: strips embedded credentials', () => {
38
+ assert.equal(
39
+ normalizeUrl('https://attacker:secret@backend.acme.com'),
40
+ 'https://backend.acme.com',
41
+ );
42
+ });
43
+
44
+ test('normalizeUrl: strips fragment', () => {
45
+ assert.equal(normalizeUrl('https://api.acme.com#anything'), 'https://api.acme.com');
46
+ });
47
+
48
+ test('normalizeUrl: rejects non-http(s) scheme', () => {
49
+ // Schemes with `://` are recognized and rejected by scheme check.
50
+ assert.throws(() => normalizeUrl('ftp://x.com'), /http or https/);
51
+ assert.throws(() => normalizeUrl('file:///etc/passwd'), /http or https/);
52
+ // Schemes without `://` (e.g. `javascript:`) get `https://` prepended,
53
+ // then fail URL parsing because the result is malformed.
54
+ assert.throws(() => normalizeUrl('javascript:alert(1)'), /Invalid URL/);
55
+ assert.throws(() => normalizeUrl('data:text/html,foo'), /Invalid URL|http or https/);
56
+ });
57
+
58
+ test('normalizeUrl: rejects empty / nullish', () => {
59
+ assert.throws(() => normalizeUrl(''), /must not be empty/);
60
+ assert.throws(() => normalizeUrl(' '), /must not be empty/);
61
+ assert.throws(() => normalizeUrl(null), /required/);
62
+ assert.throws(() => normalizeUrl(undefined), /required/);
63
+ });
64
+
65
+ test('normalizeUrl: rejects malformed input', () => {
66
+ assert.throws(() => normalizeUrl('https://'), /Invalid URL|hostname/);
67
+ });
68
+
69
+ // Setters must return the value actually written to disk, not what a getter
70
+ // would resolve to (env vars override config in getters and would mislead the
71
+ // user about what was just persisted). Regression test for PR #21 review.
72
+ test('setBaseUrl/setFrontendUrl/setGatewayUrl return the normalized written value', () => {
73
+ const fs = require('node:fs');
74
+ const os = require('node:os');
75
+ const path = require('node:path');
76
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
77
+ const origHome = process.env.HOME;
78
+ const origGw = process.env.UNBOUND_GATEWAY_URL;
79
+ process.env.HOME = tmp;
80
+ process.env.UNBOUND_GATEWAY_URL = 'https://env-override.example.com';
81
+ // Reload the module fresh so it picks up the temp HOME.
82
+ delete require.cache[require.resolve('../src/config')];
83
+ const c = require('../src/config');
84
+ try {
85
+ assert.equal(c.setBaseUrl('backend.acme.com'), 'https://backend.acme.com');
86
+ assert.equal(c.setFrontendUrl('gateway.acme.com'), 'https://gateway.acme.com');
87
+ // setGatewayUrl returns the WRITTEN value — not what getGatewayUrl() would
88
+ // return (which would be the env-var override).
89
+ assert.equal(c.setGatewayUrl('api.acme.com'), 'https://api.acme.com');
90
+ assert.equal(c.getGatewayUrl(), 'https://env-override.example.com');
91
+ } finally {
92
+ process.env.HOME = origHome;
93
+ if (origGw === undefined) delete process.env.UNBOUND_GATEWAY_URL;
94
+ else process.env.UNBOUND_GATEWAY_URL = origGw;
95
+ delete require.cache[require.resolve('../src/config')];
96
+ fs.rmSync(tmp, { recursive: true, force: true });
97
+ }
98
+ });