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 +29 -17
- package/README.md +26 -3
- package/package.json +1 -1
- package/src/commands/discover.js +9 -6
- package/src/commands/login.js +22 -5
- package/src/commands/onboard.js +41 -13
- package/src/commands/setup.js +57 -32
- package/src/commands/status.js +9 -3
- package/src/config.js +90 -6
- package/src/index.js +149 -30
- package/test/config-normalize-url.test.js +199 -0
package/LOCAL_DEV.md
CHANGED
|
@@ -28,19 +28,26 @@ unbound --help
|
|
|
28
28
|
## Point to local backend
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
# Option
|
|
32
|
-
node src/index.js config
|
|
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
|
-
|
|
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
|
|
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
|
|
194
|
-
unbound config set-url
|
|
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>`
|
|
210
|
-
| `unbound config
|
|
211
|
-
| `unbound config
|
|
212
|
-
| `unbound config set-
|
|
213
|
-
| `unbound config get-
|
|
214
|
-
| `unbound config reset-frontend-url` |
|
|
215
|
-
| `unbound config
|
|
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
package/src/commands/discover.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/commands/login.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
package/src/commands/onboard.js
CHANGED
|
@@ -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
|
|
13
|
-
* so users running against a custom
|
|
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 ===
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -207,13 +207,25 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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,
|
|
461
|
-
await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true,
|
|
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,
|
|
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
|
-
|
|
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
|
|
573
|
-
// Use optsWithGlobals() so all
|
|
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
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
686
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl } = {}) {
|
|
660
687
|
const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
661
|
-
|
|
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
|
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
152
|
+
const apiEmail = apiResponse.email;
|
|
153
|
+
if (apiEmail && cfg.email !== apiEmail) {
|
|
154
|
+
cfg.email = apiEmail;
|
|
79
155
|
changed = true;
|
|
80
156
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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('
|
|
168
|
-
.description('Set
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
['
|
|
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
|
+
});
|