unbound-cli 0.7.1 → 0.8.1

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.1",
3
+ "version": "0.8.1",
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,10 @@ 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
+ .option('--gateway-url <url>', 'Tenant AI gateway URL (e.g. https://api.acme.com). Persisted to config.')
13
+ .option('--frontend-url <url>', 'Tenant frontend URL (e.g. https://gateway.acme.com). Persisted to config.')
14
+ .option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
15
+ .addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
13
16
  .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
14
17
  .addHelpText('after', `
15
18
  Authentication methods:
@@ -22,19 +25,33 @@ Authentication methods:
22
25
  Non-interactive login for CI/CD or automation. Stores the provided
23
26
  API key directly without browser interaction.
24
27
 
28
+ Tenant deployments (URL flags persist to ~/.unbound/config.json):
29
+ Pass --gateway-url, --frontend-url, --backend-url at login time and the
30
+ CLI stores them all at once — no separate config step needed.
31
+
25
32
  Options:
26
33
  --domain sets a custom domain for organizations with self-hosted frontends.
34
+ Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
27
35
 
28
36
  Examples:
29
37
  $ unbound login # Login via default gateway
30
- $ unbound login --domain custom.example.com # Login via custom domain
31
38
  $ unbound login --api-key sk-abc123 # Non-interactive login
39
+ $ unbound login --api-key sk-abc123 \\
40
+ --gateway-url https://api.acme.com \\
41
+ --frontend-url https://gateway.acme.com \\
42
+ --backend-url https://backend.acme.com # Tenant onboarding (one shot)
43
+ $ unbound login --domain custom.example.com # Login via custom domain
32
44
  `)
33
45
  .action(async (opts) => {
34
46
  try {
35
- if (opts.baseUrl) {
36
- config.setBaseUrl(opts.baseUrl);
37
- }
47
+ // --backend-url is the canonical visible flag; --base-url is a hidden
48
+ // back-compat alias. setUrls writes all provided URLs atomically so
49
+ // a malformed --frontend-url can't leave a fresh --backend-url half-applied.
50
+ config.setUrls({
51
+ backend: opts.backendUrl || opts.baseUrl,
52
+ frontend: opts.frontendUrl,
53
+ gateway: opts.gatewayUrl,
54
+ });
38
55
 
39
56
  let apiKey;
40
57
 
@@ -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,42 @@ Examples:
50
50
  `)
51
51
  .action(async (opts) => {
52
52
  let setupSucceeded = false;
53
+ let discoveryDomain;
53
54
  try {
55
+ // Persist explicitly-passed URL flags BEFORE login so tenant URLs land
56
+ // in config before any backend call (whoami, setup completion ping) uses
57
+ // them. setUrls is atomic — a malformed URL throws before any disk write,
58
+ // so the three URLs never end up out of sync.
59
+ config.setUrls({
60
+ backend: opts.backendUrl,
61
+ frontend: opts.frontendUrl,
62
+ gateway: opts.gatewayUrl,
63
+ });
64
+ const backendUrl = config.getBaseUrl();
65
+ const frontendUrl = config.getFrontendUrl();
66
+ const gatewayUrl = config.getGatewayUrl();
67
+ discoveryDomain = opts.domain || backendUrl;
68
+
54
69
  await ensureLoggedIn({ apiKey: opts.apiKey });
55
70
  const apiKey = config.getApiKey();
56
71
 
57
72
  console.log('');
58
73
  output.info('Step 1/2: Installing tool bundle');
59
- const ok = await runSetupAllBundle(apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
74
+ const ok = await runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl });
60
75
  if (!ok) return;
61
76
  setupSucceeded = true;
62
77
 
63
78
  console.log('');
64
79
  output.info('Step 2/2: Running device discovery');
65
80
  console.log('');
66
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: opts.domain });
81
+ await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
67
82
 
68
83
  console.log('');
69
84
  output.success('Onboarding complete');
70
85
  } catch (err) {
71
86
  if (!err.displayed) output.error(err.message);
72
87
  if (setupSucceeded) {
73
- const suffix = domainHintSuffix(opts.domain);
88
+ const suffix = domainHintSuffix(discoveryDomain);
74
89
  console.error(' Tool setup completed successfully — only discovery failed.');
75
90
  console.error(` Re-run discovery only with: unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
76
91
  }
@@ -88,9 +103,10 @@ Examples:
88
103
  )
89
104
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
90
105
  .requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
91
- .option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
106
+ .option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
92
107
  .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
93
108
  .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
109
+ .addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
94
110
  .addHelpText('after', `
95
111
  Runs the full MDM onboarding flow for device enrollment:
96
112
  1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
@@ -106,26 +122,38 @@ Examples:
106
122
  `)
107
123
  .action(async (opts) => {
108
124
  let setupSucceeded = false;
125
+ let discoveryDomain;
109
126
  try {
127
+ // Persist explicitly-passed URL flags so subsequent commands on this
128
+ // device (e.g. unbound discover, unbound setup) hit the same tenant.
129
+ // setUrls is atomic — a malformed URL throws before any disk write.
130
+ config.setUrls({
131
+ backend: opts.backendUrl,
132
+ gateway: opts.gatewayUrl,
133
+ });
134
+ const backendUrl = config.getBaseUrl();
135
+ const gatewayUrl = config.getGatewayUrl();
136
+ discoveryDomain = opts.domain || backendUrl;
137
+
110
138
  checkRoot('onboard-mdm');
111
139
 
112
140
  console.log('');
113
141
  output.info('Step 1/2: Installing MDM tool bundle');
114
- const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
142
+ const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl, gatewayUrl });
115
143
  if (!ok) return;
116
144
  setupSucceeded = true;
117
145
 
118
146
  console.log('');
119
147
  output.info('Step 2/2: Running device discovery');
120
148
  console.log('');
121
- await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: opts.domain });
149
+ await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
122
150
 
123
151
  console.log('');
124
152
  output.success('MDM onboarding complete');
125
153
  } catch (err) {
126
154
  if (!err.displayed) output.error(err.message);
127
155
  if (setupSucceeded) {
128
- const suffix = domainHintSuffix(opts.domain);
156
+ const suffix = domainHintSuffix(discoveryDomain);
129
157
  console.error(' MDM tool setup completed successfully — only discovery failed.');
130
158
  console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
131
159
  }
@@ -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
@@ -354,9 +367,22 @@ automatically to authenticate before proceeding.
354
367
  `)
355
368
  .action(async (tools, opts) => {
356
369
  try {
370
+ // Persist explicitly-passed URL flags BEFORE ensureLoggedIn runs so a
371
+ // tenant-onboarding `unbound setup --gateway-url ... --api-key ...` lands
372
+ // tenant URLs in config before any backend call uses them. setUrls is
373
+ // atomic — no partial writes if any value is malformed.
374
+ config.setUrls({
375
+ backend: opts.backendUrl,
376
+ frontend: opts.frontendUrl,
377
+ gateway: opts.gatewayUrl,
378
+ });
379
+
357
380
  await ensureLoggedIn({ apiKey: opts.apiKey });
358
381
  const apiKey = config.getApiKey();
382
+ const backendUrl = config.getBaseUrl();
359
383
  const frontendUrl = config.getFrontendUrl();
384
+ const gatewayUrl = config.getGatewayUrl();
385
+ const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
360
386
 
361
387
  // --all expands to the default bundle. Cannot be combined with explicit tool names.
362
388
  if (opts.all) {
@@ -383,9 +409,7 @@ automatically to authenticate before proceeding.
383
409
  const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
384
410
  console.log('');
385
411
 
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)}`;
412
+ const interactiveArgs = buildScriptArgs(apiKey, urlOpts);
389
413
  const ok = await runBatch(selectedTools, (tool) =>
390
414
  runScriptPiped(tool.script, interactiveArgs)
391
415
  );
@@ -452,13 +476,13 @@ automatically to authenticate before proceeding.
452
476
  const toolName = tools[0];
453
477
 
454
478
  if (SETUP_TOOL_MAP[toolName]) {
455
- await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
479
+ await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, ...urlOpts });
456
480
  } else if (MODE_TOOLS[toolName]) {
457
481
  const mode = MODE_TOOLS[toolName];
458
482
  if (opts.clear) {
459
483
  // 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 });
484
+ await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, ...urlOpts });
485
+ await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, ...urlOpts });
462
486
  } else {
463
487
  let useSubscription = opts.subscription;
464
488
  if (!opts.subscription && !opts.gateway) {
@@ -466,7 +490,7 @@ automatically to authenticate before proceeding.
466
490
  useSubscription = choice === 'subscription';
467
491
  }
468
492
  const resolved = useSubscription ? mode.subscription : mode.gateway;
469
- await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
493
+ await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, urlOpts);
470
494
  }
471
495
  } else if (INSTRUCTION_TOOLS[toolName]) {
472
496
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
@@ -504,10 +528,7 @@ automatically to authenticate before proceeding.
504
528
  // Run automated tools with spinners
505
529
  if (resolvedScripts.length > 0) {
506
530
  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';
531
+ const args = buildScriptArgs(apiKey, { ...urlOpts, clear: opts.clear });
511
532
  const ok = await runBatch(resolvedScripts, (tool) =>
512
533
  runScriptPiped(tool.script, args)
513
534
  , { clear: opts.clear });
@@ -569,9 +590,18 @@ Examples:
569
590
  try {
570
591
  checkRoot();
571
592
  // --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`.
593
+ // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
594
+ // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
574
595
  const globalOpts = command.optsWithGlobals();
596
+ // Persist explicitly-passed URL flags so this MDM run also configures
597
+ // the CLI for any subsequent non-MDM commands on the same machine.
598
+ // setUrls is atomic — no partial writes if any value is malformed.
599
+ config.setUrls({
600
+ backend: globalOpts.backendUrl,
601
+ gateway: globalOpts.gatewayUrl,
602
+ });
603
+ const backendUrl = config.getBaseUrl();
604
+ const gatewayUrl = config.getGatewayUrl();
575
605
 
576
606
  if (globalOpts.all && tools.length > 0) {
577
607
  output.error('Cannot combine --all with specific tool names. Use one or the other.');
@@ -614,17 +644,16 @@ Examples:
614
644
  const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
615
645
  console.log('');
616
646
 
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
- };
647
+ const args = buildScriptArgs(opts.adminApiKey, {
648
+ backendUrl,
649
+ gatewayUrl,
650
+ clear: globalOpts.clear,
651
+ mdm: true,
652
+ });
624
653
 
625
654
  const ok = await runBatch(
626
655
  resolvedTools,
627
- (tool) => runScriptPiped(tool.script, mdmArgs(tool)),
656
+ (tool) => runScriptPiped(tool.script, args),
628
657
  { clear: globalOpts.clear }
629
658
  );
630
659
  if (!ok) return;
@@ -643,11 +672,9 @@ Examples:
643
672
  * Assumes the caller has already ensured the user is logged in.
644
673
  * Returns true on success, false on failure.
645
674
  */
646
- async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
675
+ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl } = {}) {
647
676
  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)}`;
677
+ const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl });
651
678
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
652
679
  }
653
680
 
@@ -656,11 +683,9 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
656
683
  * Caller must ensure the process is running as root.
657
684
  * Returns true on success, false on failure.
658
685
  */
659
- async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl } = {}) {
686
+ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl } = {}) {
660
687
  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)}`;
688
+ const args = buildScriptArgs(adminApiKey, { backendUrl, gatewayUrl, mdm: true });
664
689
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
665
690
  }
666
691
 
@@ -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,44 @@ 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
+ /**
107
+ * Atomic multi-URL setter. Accepts any subset of `{gateway, frontend, backend}`
108
+ * (each field is independently optional). Normalizes every provided value up
109
+ * front; if any one is invalid it throws BEFORE touching disk so the caller
110
+ * never lands a partial write that leaves the three URLs out of sync.
111
+ *
112
+ * Use this from any code path that may persist more than one URL flag in the
113
+ * same command (login/setup/onboard) so a malformed --frontend-url can't
114
+ * succeed in writing a fresh --backend-url first.
115
+ *
116
+ * Returns an object with only the keys that were updated, normalized.
117
+ */
118
+ function setUrls({ gateway, frontend, backend } = {}) {
119
+ const normalized = {};
120
+ if (gateway !== undefined) normalized.gateway_url = normalizeUrl(gateway);
121
+ if (frontend !== undefined) normalized.frontend_url = normalizeUrl(frontend);
122
+ if (backend !== undefined) normalized.base_url = normalizeUrl(backend);
123
+ if (Object.keys(normalized).length === 0) return normalized;
124
+ const config = readConfig();
125
+ Object.assign(config, normalized);
60
126
  writeConfig(config);
127
+ return normalized;
61
128
  }
62
129
 
63
130
  function clearConfig() {
@@ -70,16 +137,26 @@ function isLoggedIn() {
70
137
  return !!getApiKey();
71
138
  }
72
139
 
140
+ /**
141
+ * Refreshes cached user identity (email, org_name) from a backend response.
142
+ * Always overwrites when the response carries a non-empty value so that
143
+ * switching tenants under the same API key (or rotating the key to a new org)
144
+ * shows the correct organization in `whoami` / `status` instead of stale data.
145
+ * Defensive: leaves an existing cached value untouched if the response field
146
+ * is missing or empty, so a partial API response can't blank the local config.
147
+ */
73
148
  function backfillUserInfo(apiResponse) {
74
149
  if (!apiResponse) return;
75
150
  const cfg = readConfig();
76
151
  let changed = false;
77
- if (!cfg.email && apiResponse.email) {
78
- cfg.email = apiResponse.email;
152
+ const apiEmail = apiResponse.email;
153
+ if (apiEmail && cfg.email !== apiEmail) {
154
+ cfg.email = apiEmail;
79
155
  changed = true;
80
156
  }
81
- if (!cfg.org_name && (apiResponse.org_name || apiResponse.organization_name || apiResponse.organization)) {
82
- cfg.org_name = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
157
+ const apiOrg = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
158
+ if (apiOrg && cfg.org_name !== apiOrg) {
159
+ cfg.org_name = apiOrg;
83
160
  changed = true;
84
161
  }
85
162
  if (changed) writeConfig(cfg);
@@ -88,6 +165,10 @@ function backfillUserInfo(apiResponse) {
88
165
  module.exports = {
89
166
  CONFIG_DIR,
90
167
  CONFIG_FILE,
168
+ DEFAULT_BASE_URL,
169
+ DEFAULT_FRONTEND_URL,
170
+ DEFAULT_GATEWAY_URL,
171
+ normalizeUrl,
91
172
  ensureConfigDir,
92
173
  readConfig,
93
174
  writeConfig,
@@ -97,6 +178,9 @@ module.exports = {
97
178
  setBaseUrl,
98
179
  getFrontendUrl,
99
180
  setFrontendUrl,
181
+ getGatewayUrl,
182
+ setGatewayUrl,
183
+ setUrls,
100
184
  clearConfig,
101
185
  isLoggedIn,
102
186
  backfillUserInfo,
package/src/index.js CHANGED
@@ -32,6 +32,18 @@ AUTHENTICATION
32
32
  $ unbound whoami Show current user and organization
33
33
  $ unbound status Show CLI status and API connectivity
34
34
 
35
+ Tenant deployments — pass URL flags on login; they persist to ~/.unbound/config.json:
36
+ $ unbound login --api-key <YOUR_API_KEY> \\
37
+ --gateway-url <gw> --frontend-url <fe> --backend-url <be>
38
+ Example:
39
+ $ unbound login --api-key sk-... \\
40
+ --gateway-url https://api.acme.com \\
41
+ --frontend-url https://gateway.acme.com \\
42
+ --backend-url https://backend.acme.com
43
+
44
+ Or set URLs separately (any time):
45
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
46
+
35
47
  ONBOARDING (one-step install + discover)
36
48
  $ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
37
49
  $ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
@@ -73,7 +85,7 @@ MDM SETUP (admin, requires root)
73
85
  $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
74
86
 
75
87
  MDM AI TOOLS DISCOVERY
76
- --domain defaults to https://backend.getunbound.ai
88
+ --domain defaults to the configured backend URL (set via "unbound config set-backend-url")
77
89
  $ sudo unbound discover --api-key KEY Scan all users (requires root)
78
90
  $ unbound discover --api-key KEY Scan current user only
79
91
  $ sudo unbound discover --api-key KEY --domain https://custom.backend.com
@@ -133,7 +145,20 @@ CHAT (Admin/Manager)
133
145
  See "unbound chat --help" for the response shape and REPL slash commands.
134
146
 
135
147
  CONFIGURATION
136
- $ unbound config show Show all settings
148
+ Defaults point to api/gateway/backend.getunbound.ai. Tenant deployments override them.
149
+
150
+ One-shot setup (recommended for new tenants):
151
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
152
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
153
+
154
+ Per-URL setters (change one host at a time):
155
+ $ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
156
+ $ unbound config set-frontend-url <url> Frontend host (browser login flow)
157
+ $ unbound config set-backend-url <url> Backend REST API host
158
+
159
+ View:
160
+ $ unbound config show All URLs + login state
161
+ $ unbound config show --json Machine-readable
137
162
 
138
163
  FILES
139
164
  ~/.unbound/config.json Credentials and CLI settings
@@ -160,52 +185,127 @@ require('./commands/chat').register(program);
160
185
  // config command for managing CLI settings
161
186
  const configCmd = program
162
187
  .command('config')
163
- .description('Manage CLI configuration settings.');
188
+ .description('Manage CLI configuration settings (tenant URLs, view current state).')
189
+ .addHelpText('after', `
190
+ Tenant onboarding (one-shot — recommended):
191
+ $ unbound config urls <gateway-url> <frontend-url> <backend-url>
192
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
193
+
194
+ Per-URL setters (use when changing one host at a time):
195
+ $ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
196
+ $ unbound config set-frontend-url <url> Browser login host
197
+ $ unbound config set-backend-url <url> REST API host
198
+
199
+ View:
200
+ $ unbound config show Print all values, including defaults
201
+ $ unbound config show --json Same, machine-readable
202
+
203
+ Notes:
204
+ - Bare hostnames are accepted (e.g. "api.acme.com" → "https://api.acme.com").
205
+ - Settings persist in ~/.unbound/config.json. Defaults: api/gateway/backend.getunbound.ai.
206
+ - Run "unbound login --api-key <key>" after configuring URLs (no browser needed).
207
+ `);
164
208
 
165
- // Internal/dev commands hidden from help but still functional
209
+ // Reports the value that was actually written to ~/.unbound/config.json,
210
+ // not what the getter would resolve to. The getter applies env-var precedence
211
+ // (UNBOUND_*_URL), which would mislead the user if they had an override set —
212
+ // the message would echo the env var instead of the value they just typed.
213
+ function safeSet(label, fn, url) {
214
+ try {
215
+ const written = fn(url);
216
+ output.success(`${label} set to ${written}`);
217
+ } catch (err) {
218
+ output.error(err.message);
219
+ process.exitCode = 1;
220
+ }
221
+ }
222
+
223
+ // Visible: one-shot setter for all three tenant URLs.
224
+ // Positional order: gateway, frontend, backend — same order as the help text and docs.
166
225
  configCmd
167
- .command('set-url <url>', { hidden: true })
168
- .description('Set the API base URL (internal)')
169
- .action((url) => {
170
- config.setBaseUrl(url);
171
- output.success(`API base URL set to ${url}`);
226
+ .command('urls <gateway-url> <frontend-url> <backend-url>')
227
+ .description('Set all three tenant URLs at once (gateway, frontend, backend).')
228
+ .allowExcessArguments(false)
229
+ .addHelpText('after', `
230
+ Use this on a fresh install for tenant deployments. Positional order is fixed:
231
+ 1. <gateway-url> — AI gateway host (e.g. https://api.acme.com)
232
+ Wired into tool setup scripts (ANTHROPIC_BASE_URL, codex config, etc.)
233
+ 2. <frontend-url> — Frontend host (e.g. https://gateway.acme.com)
234
+ Used by the browser login flow.
235
+ 3. <backend-url> — REST API host (e.g. https://backend.acme.com)
236
+ All "unbound *" commands (whoami, status, policies, ...) hit this.
237
+
238
+ Bare hostnames are accepted; "https://" is added automatically.
239
+ The three values are written atomically to ~/.unbound/config.json.
240
+ After running this, log in with: unbound login --api-key <YOUR_API_KEY>
241
+
242
+ Examples:
243
+ $ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
244
+ $ unbound config urls api.acme.com gateway.acme.com backend.acme.com
245
+ $ unbound config show
246
+ `)
247
+ .action((gateway, frontend, backend) => {
248
+ try {
249
+ const written = config.setUrls({ gateway, frontend, backend });
250
+ output.success('All three tenant URLs set:');
251
+ output.keyValue([
252
+ ['Gateway URL', written.gateway_url],
253
+ ['Frontend URL', written.frontend_url],
254
+ ['Backend URL', written.base_url],
255
+ ]);
256
+ console.log('');
257
+ output.info('Next: unbound login --api-key <YOUR_API_KEY>');
258
+ } catch (err) {
259
+ output.error(err.message);
260
+ process.exitCode = 1;
261
+ }
172
262
  });
173
263
 
264
+ // Per-URL setters (use when changing a single host).
265
+ configCmd
266
+ .command('set-backend-url <url>')
267
+ .description('Set the backend REST API URL (tenant deployments).')
268
+ .action((url) => safeSet('Backend URL', config.setBaseUrl, url));
269
+
270
+ configCmd
271
+ .command('set-frontend-url <url>')
272
+ .description('Set the frontend URL used by the browser login flow.')
273
+ .action((url) => safeSet('Frontend URL', config.setFrontendUrl, url));
274
+
275
+ configCmd
276
+ .command('set-gateway-url <url>')
277
+ .description('Set the AI gateway URL (used by tool setup scripts).')
278
+ .action((url) => safeSet('Gateway URL', config.setGatewayUrl, url));
279
+
280
+ // Hidden back-compat aliases — keep working for existing scripts/users.
281
+ configCmd
282
+ .command('set-url <url>', { hidden: true })
283
+ .description('Alias of set-backend-url (kept for backward compatibility).')
284
+ .action((url) => safeSet('Backend URL', config.setBaseUrl, url));
285
+
174
286
  configCmd
175
287
  .command('get-url', { hidden: true })
176
- .description('Show the current API base URL (internal)')
177
- .action(() => {
178
- console.log(config.getBaseUrl());
179
- });
288
+ .description('Show the current backend URL (kept for backward compatibility).')
289
+ .action(() => { console.log(config.getBaseUrl()); });
180
290
 
181
291
  configCmd
182
292
  .command('reset-url', { hidden: true })
183
- .description('Reset the API base URL to default (internal)')
293
+ .description('Reset the backend URL to default (kept for backward compatibility).')
184
294
  .action(() => {
185
295
  const cfg = config.readConfig();
186
296
  delete cfg.base_url;
187
297
  config.writeConfig(cfg);
188
- output.success(`API base URL reset to ${config.getBaseUrl()}`);
189
- });
190
-
191
- configCmd
192
- .command('set-frontend-url <url>', { hidden: true })
193
- .description('Set the frontend URL (internal)')
194
- .action((url) => {
195
- config.setFrontendUrl(url);
196
- output.success(`Frontend URL set to ${url}`);
298
+ output.success(`Backend URL reset to ${config.getBaseUrl()}`);
197
299
  });
198
300
 
199
301
  configCmd
200
302
  .command('get-frontend-url', { hidden: true })
201
- .description('Show the current frontend URL (internal)')
202
- .action(() => {
203
- console.log(config.getFrontendUrl());
204
- });
303
+ .description('Show the current frontend URL (kept for backward compatibility).')
304
+ .action(() => { console.log(config.getFrontendUrl()); });
205
305
 
206
306
  configCmd
207
307
  .command('reset-frontend-url', { hidden: true })
208
- .description('Reset the frontend URL to default (internal)')
308
+ .description('Reset the frontend URL to default (kept for backward compatibility).')
209
309
  .action(() => {
210
310
  const cfg = config.readConfig();
211
311
  delete cfg.frontend_url;
@@ -213,6 +313,21 @@ configCmd
213
313
  output.success(`Frontend URL reset to ${config.getFrontendUrl()}`);
214
314
  });
215
315
 
316
+ configCmd
317
+ .command('get-gateway-url', { hidden: true })
318
+ .description('Show the current gateway URL.')
319
+ .action(() => { console.log(config.getGatewayUrl()); });
320
+
321
+ configCmd
322
+ .command('reset-gateway-url', { hidden: true })
323
+ .description('Reset the gateway URL to default.')
324
+ .action(() => {
325
+ const cfg = config.readConfig();
326
+ delete cfg.gateway_url;
327
+ config.writeConfig(cfg);
328
+ output.success(`Gateway URL reset to ${config.getGatewayUrl()}`);
329
+ });
330
+
216
331
  configCmd
217
332
  .command('show')
218
333
  .description('Show all current configuration values.')
@@ -223,7 +338,9 @@ configCmd
223
338
  if (opts.json) {
224
339
  output.json({
225
340
  config_file: config.CONFIG_FILE,
226
- gateway_url: config.getFrontendUrl(),
341
+ backend_url: config.getBaseUrl(),
342
+ frontend_url: config.getFrontendUrl(),
343
+ gateway_url: config.getGatewayUrl(),
227
344
  logged_in: config.isLoggedIn(),
228
345
  email: cfg.email || null,
229
346
  organization: cfg.org_name || null,
@@ -233,7 +350,9 @@ configCmd
233
350
 
234
351
  output.keyValue([
235
352
  ['Config file', config.CONFIG_FILE],
236
- ['Unbound Gateway', config.getFrontendUrl()],
353
+ ['Backend URL', config.getBaseUrl()],
354
+ ['Frontend URL', config.getFrontendUrl()],
355
+ ['Gateway URL', config.getGatewayUrl()],
237
356
  ['Logged in', config.isLoggedIn() ? 'Yes' : 'No'],
238
357
  ['Email', cfg.email || '-'],
239
358
  ['Organization', cfg.org_name || '-'],
@@ -0,0 +1,199 @@
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
+ });
99
+
100
+ // WEB-4103 (PR #22 review): setUrls must be atomic — if any URL fails
101
+ // normalization, NONE of them should be persisted. Previously, separate
102
+ // setBaseUrl/setFrontendUrl/setGatewayUrl calls did sequential writes,
103
+ // so a malformed --frontend-url would land --backend-url half-applied.
104
+ test('setUrls is atomic — partial failure leaves config unchanged', () => {
105
+ const fs = require('node:fs');
106
+ const os = require('node:os');
107
+ const path = require('node:path');
108
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
109
+ const origHome = process.env.HOME;
110
+ process.env.HOME = tmp;
111
+ delete require.cache[require.resolve('../src/config')];
112
+ const c = require('../src/config');
113
+ try {
114
+ // Seed a known starting state (simulating an existing tenant config).
115
+ c.writeConfig({
116
+ api_key: 'sk-x',
117
+ base_url: 'https://old-backend.example.com',
118
+ frontend_url: 'https://old-frontend.example.com',
119
+ gateway_url: 'https://old-gateway.example.com',
120
+ });
121
+
122
+ // Attempt to set all three; the frontend URL is malformed.
123
+ assert.throws(
124
+ () => c.setUrls({
125
+ backend: 'https://new-backend.example.com',
126
+ frontend: 'javascript:alert(1)',
127
+ gateway: 'https://new-gateway.example.com',
128
+ }),
129
+ /Invalid URL|http or https/,
130
+ );
131
+
132
+ // Critical: NO field should have been persisted — all three still old.
133
+ const cfg = c.readConfig();
134
+ assert.equal(cfg.base_url, 'https://old-backend.example.com');
135
+ assert.equal(cfg.frontend_url, 'https://old-frontend.example.com');
136
+ assert.equal(cfg.gateway_url, 'https://old-gateway.example.com');
137
+
138
+ // Partial input still works atomically.
139
+ c.setUrls({ backend: 'new.example.com' });
140
+ assert.equal(c.readConfig().base_url, 'https://new.example.com');
141
+ assert.equal(c.readConfig().frontend_url, 'https://old-frontend.example.com');
142
+
143
+ // Empty input is a no-op.
144
+ const result = c.setUrls({});
145
+ assert.deepEqual(result, {});
146
+ c.setUrls();
147
+ } finally {
148
+ process.env.HOME = origHome;
149
+ delete require.cache[require.resolve('../src/config')];
150
+ fs.rmSync(tmp, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ // WEB-4103: backfillUserInfo must refresh email/org_name when the API returns
155
+ // a different value (e.g. user switched tenants), not just fill if missing.
156
+ // Defensive: must NOT blank the cached value on a partial/empty API response.
157
+ test('backfillUserInfo refreshes stale email/org but preserves on empty', () => {
158
+ const fs = require('node:fs');
159
+ const os = require('node:os');
160
+ const path = require('node:path');
161
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
162
+ const origHome = process.env.HOME;
163
+ process.env.HOME = tmp;
164
+ delete require.cache[require.resolve('../src/config')];
165
+ const c = require('../src/config');
166
+ try {
167
+ // Seed config as if the user logged in to org1 yesterday.
168
+ c.writeConfig({ api_key: 'sk-x', email: 'user@old.com', org_name: 'OldOrg' });
169
+
170
+ // API now returns the new tenant's identity → cache must update.
171
+ c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
172
+ assert.equal(c.readConfig().email, 'user@new.com');
173
+ assert.equal(c.readConfig().org_name, 'NewOrg');
174
+
175
+ // Subsequent call with same data → no-op (idempotent).
176
+ c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
177
+ assert.equal(c.readConfig().email, 'user@new.com');
178
+
179
+ // Partial / missing API response → cache preserved, NOT blanked.
180
+ c.backfillUserInfo({ email: 'user@new.com' });
181
+ assert.equal(c.readConfig().org_name, 'NewOrg');
182
+ c.backfillUserInfo({});
183
+ assert.equal(c.readConfig().email, 'user@new.com');
184
+ assert.equal(c.readConfig().org_name, 'NewOrg');
185
+
186
+ // Backend returning the legacy `organization_name` key still works.
187
+ c.backfillUserInfo({ organization_name: 'AnotherOrg' });
188
+ assert.equal(c.readConfig().org_name, 'AnotherOrg');
189
+
190
+ // Null / undefined input is a safe no-op.
191
+ c.backfillUserInfo(null);
192
+ c.backfillUserInfo(undefined);
193
+ assert.equal(c.readConfig().email, 'user@new.com');
194
+ } finally {
195
+ process.env.HOME = origHome;
196
+ delete require.cache[require.resolve('../src/config')];
197
+ fs.rmSync(tmp, { recursive: true, force: true });
198
+ }
199
+ });