unbound-cli 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LOCAL_DEV.md +29 -17
- package/README.md +26 -3
- package/package.json +1 -1
- package/src/commands/discover.js +9 -6
- package/src/commands/login.js +13 -4
- package/src/commands/onboard.js +21 -13
- package/src/commands/setup.js +41 -33
- package/src/commands/status.js +9 -3
- package/src/config.js +64 -2
- package/src/index.js +144 -30
- package/test/config-normalize-url.test.js +98 -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,9 @@ function register(program) {
|
|
|
9
9
|
.command('login')
|
|
10
10
|
.description('Authenticate with Unbound. Opens a browser for interactive login, or use --api-key for CI/CD environments.')
|
|
11
11
|
.option('--api-key <key>', 'Authenticate with an API key directly (non-interactive)')
|
|
12
|
-
.addOption(new Option('--base-url <url>', 'Set a custom
|
|
12
|
+
.addOption(new Option('--base-url <url>', 'Set a custom backend URL (also persists to config)').hideHelp())
|
|
13
|
+
.addOption(new Option('--frontend-url <url>', 'Set a custom frontend URL (also persists to config)').hideHelp())
|
|
14
|
+
.addOption(new Option('--gateway-url <url>', 'Set a custom gateway URL (also persists to config)').hideHelp())
|
|
13
15
|
.option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
|
|
14
16
|
.addHelpText('after', `
|
|
15
17
|
Authentication methods:
|
|
@@ -25,16 +27,23 @@ Authentication methods:
|
|
|
25
27
|
Options:
|
|
26
28
|
--domain sets a custom domain for organizations with self-hosted frontends.
|
|
27
29
|
|
|
30
|
+
Tenant deployments:
|
|
31
|
+
Before logging in to a non-default Unbound tenant, set all three URLs:
|
|
32
|
+
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
33
|
+
$ unbound login --api-key <YOUR_API_KEY>
|
|
34
|
+
|
|
28
35
|
Examples:
|
|
29
36
|
$ unbound login # Login via default gateway
|
|
30
37
|
$ unbound login --domain custom.example.com # Login via custom domain
|
|
31
38
|
$ unbound login --api-key sk-abc123 # Non-interactive login
|
|
39
|
+
$ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com \\
|
|
40
|
+
&& unbound login --api-key sk-abc123 # Tenant onboarding
|
|
32
41
|
`)
|
|
33
42
|
.action(async (opts) => {
|
|
34
43
|
try {
|
|
35
|
-
if (opts.baseUrl)
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
if (opts.baseUrl) config.setBaseUrl(opts.baseUrl);
|
|
45
|
+
if (opts.frontendUrl) config.setFrontendUrl(opts.frontendUrl);
|
|
46
|
+
if (opts.gatewayUrl) config.setGatewayUrl(opts.gatewayUrl);
|
|
38
47
|
|
|
39
48
|
let apiKey;
|
|
40
49
|
|
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,31 @@ Examples:
|
|
|
50
50
|
`)
|
|
51
51
|
.action(async (opts) => {
|
|
52
52
|
let setupSucceeded = false;
|
|
53
|
+
const backendUrl = opts.backendUrl || config.getBaseUrl();
|
|
54
|
+
const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
|
|
55
|
+
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
56
|
+
const discoveryDomain = opts.domain || backendUrl;
|
|
53
57
|
try {
|
|
54
58
|
await ensureLoggedIn({ apiKey: opts.apiKey });
|
|
55
59
|
const apiKey = config.getApiKey();
|
|
56
60
|
|
|
57
61
|
console.log('');
|
|
58
62
|
output.info('Step 1/2: Installing tool bundle');
|
|
59
|
-
const ok = await runSetupAllBundle(apiKey, { backendUrl
|
|
63
|
+
const ok = await runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl });
|
|
60
64
|
if (!ok) return;
|
|
61
65
|
setupSucceeded = true;
|
|
62
66
|
|
|
63
67
|
console.log('');
|
|
64
68
|
output.info('Step 2/2: Running device discovery');
|
|
65
69
|
console.log('');
|
|
66
|
-
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain:
|
|
70
|
+
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
|
|
67
71
|
|
|
68
72
|
console.log('');
|
|
69
73
|
output.success('Onboarding complete');
|
|
70
74
|
} catch (err) {
|
|
71
75
|
if (!err.displayed) output.error(err.message);
|
|
72
76
|
if (setupSucceeded) {
|
|
73
|
-
const suffix = domainHintSuffix(
|
|
77
|
+
const suffix = domainHintSuffix(discoveryDomain);
|
|
74
78
|
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
75
79
|
console.error(` Re-run discovery only with: unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
|
|
76
80
|
}
|
|
@@ -88,9 +92,10 @@ Examples:
|
|
|
88
92
|
)
|
|
89
93
|
.requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
|
|
90
94
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
91
|
-
.option('--domain <url>', 'Backend URL for discovery
|
|
95
|
+
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
92
96
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
93
97
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
98
|
+
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
94
99
|
.addHelpText('after', `
|
|
95
100
|
Runs the full MDM onboarding flow for device enrollment:
|
|
96
101
|
1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
|
|
@@ -106,26 +111,29 @@ Examples:
|
|
|
106
111
|
`)
|
|
107
112
|
.action(async (opts) => {
|
|
108
113
|
let setupSucceeded = false;
|
|
114
|
+
const backendUrl = opts.backendUrl || config.getBaseUrl();
|
|
115
|
+
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
116
|
+
const discoveryDomain = opts.domain || backendUrl;
|
|
109
117
|
try {
|
|
110
118
|
checkRoot('onboard-mdm');
|
|
111
119
|
|
|
112
120
|
console.log('');
|
|
113
121
|
output.info('Step 1/2: Installing MDM tool bundle');
|
|
114
|
-
const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl
|
|
122
|
+
const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl, gatewayUrl });
|
|
115
123
|
if (!ok) return;
|
|
116
124
|
setupSucceeded = true;
|
|
117
125
|
|
|
118
126
|
console.log('');
|
|
119
127
|
output.info('Step 2/2: Running device discovery');
|
|
120
128
|
console.log('');
|
|
121
|
-
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain:
|
|
129
|
+
await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
|
|
122
130
|
|
|
123
131
|
console.log('');
|
|
124
132
|
output.success('MDM onboarding complete');
|
|
125
133
|
} catch (err) {
|
|
126
134
|
if (!err.displayed) output.error(err.message);
|
|
127
135
|
if (setupSucceeded) {
|
|
128
|
-
const suffix = domainHintSuffix(
|
|
136
|
+
const suffix = domainHintSuffix(discoveryDomain);
|
|
129
137
|
console.error(' MDM tool setup completed successfully — only discovery failed.');
|
|
130
138
|
console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
|
|
131
139
|
}
|
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
|
|
@@ -356,7 +369,10 @@ automatically to authenticate before proceeding.
|
|
|
356
369
|
try {
|
|
357
370
|
await ensureLoggedIn({ apiKey: opts.apiKey });
|
|
358
371
|
const apiKey = config.getApiKey();
|
|
359
|
-
const
|
|
372
|
+
const backendUrl = opts.backendUrl || config.getBaseUrl();
|
|
373
|
+
const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
|
|
374
|
+
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
375
|
+
const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
|
|
360
376
|
|
|
361
377
|
// --all expands to the default bundle. Cannot be combined with explicit tool names.
|
|
362
378
|
if (opts.all) {
|
|
@@ -383,9 +399,7 @@ automatically to authenticate before proceeding.
|
|
|
383
399
|
const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
|
|
384
400
|
console.log('');
|
|
385
401
|
|
|
386
|
-
|
|
387
|
-
if (opts.backendUrl) interactiveArgs += ` --backend-url ${shellEscape(opts.backendUrl)}`;
|
|
388
|
-
if (opts.frontendUrl) interactiveArgs += ` --domain ${shellEscape(opts.frontendUrl)}`;
|
|
402
|
+
const interactiveArgs = buildScriptArgs(apiKey, urlOpts);
|
|
389
403
|
const ok = await runBatch(selectedTools, (tool) =>
|
|
390
404
|
runScriptPiped(tool.script, interactiveArgs)
|
|
391
405
|
);
|
|
@@ -452,13 +466,13 @@ automatically to authenticate before proceeding.
|
|
|
452
466
|
const toolName = tools[0];
|
|
453
467
|
|
|
454
468
|
if (SETUP_TOOL_MAP[toolName]) {
|
|
455
|
-
await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear,
|
|
469
|
+
await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, ...urlOpts });
|
|
456
470
|
} else if (MODE_TOOLS[toolName]) {
|
|
457
471
|
const mode = MODE_TOOLS[toolName];
|
|
458
472
|
if (opts.clear) {
|
|
459
473
|
// Clear both modes
|
|
460
|
-
await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true,
|
|
461
|
-
await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true,
|
|
474
|
+
await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, ...urlOpts });
|
|
475
|
+
await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, ...urlOpts });
|
|
462
476
|
} else {
|
|
463
477
|
let useSubscription = opts.subscription;
|
|
464
478
|
if (!opts.subscription && !opts.gateway) {
|
|
@@ -466,7 +480,7 @@ automatically to authenticate before proceeding.
|
|
|
466
480
|
useSubscription = choice === 'subscription';
|
|
467
481
|
}
|
|
468
482
|
const resolved = useSubscription ? mode.subscription : mode.gateway;
|
|
469
|
-
await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey,
|
|
483
|
+
await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, urlOpts);
|
|
470
484
|
}
|
|
471
485
|
} else if (INSTRUCTION_TOOLS[toolName]) {
|
|
472
486
|
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
|
|
@@ -504,10 +518,7 @@ automatically to authenticate before proceeding.
|
|
|
504
518
|
// Run automated tools with spinners
|
|
505
519
|
if (resolvedScripts.length > 0) {
|
|
506
520
|
console.log('');
|
|
507
|
-
|
|
508
|
-
if (opts.backendUrl) args += ` --backend-url ${shellEscape(opts.backendUrl)}`;
|
|
509
|
-
if (opts.frontendUrl) args += ` --domain ${shellEscape(opts.frontendUrl)}`;
|
|
510
|
-
if (opts.clear) args += ' --clear';
|
|
521
|
+
const args = buildScriptArgs(apiKey, { ...urlOpts, clear: opts.clear });
|
|
511
522
|
const ok = await runBatch(resolvedScripts, (tool) =>
|
|
512
523
|
runScriptPiped(tool.script, args)
|
|
513
524
|
, { clear: opts.clear });
|
|
@@ -569,9 +580,11 @@ Examples:
|
|
|
569
580
|
try {
|
|
570
581
|
checkRoot();
|
|
571
582
|
// --all and --clear are defined on both this command and the parent `setup` command;
|
|
572
|
-
// --backend-url
|
|
573
|
-
// Use optsWithGlobals() so all
|
|
583
|
+
// --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
|
|
584
|
+
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
574
585
|
const globalOpts = command.optsWithGlobals();
|
|
586
|
+
const backendUrl = globalOpts.backendUrl || config.getBaseUrl();
|
|
587
|
+
const gatewayUrl = globalOpts.gatewayUrl || config.getGatewayUrl();
|
|
575
588
|
|
|
576
589
|
if (globalOpts.all && tools.length > 0) {
|
|
577
590
|
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
|
@@ -614,17 +627,16 @@ Examples:
|
|
|
614
627
|
const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
615
628
|
console.log('');
|
|
616
629
|
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
};
|
|
630
|
+
const args = buildScriptArgs(opts.adminApiKey, {
|
|
631
|
+
backendUrl,
|
|
632
|
+
gatewayUrl,
|
|
633
|
+
clear: globalOpts.clear,
|
|
634
|
+
mdm: true,
|
|
635
|
+
});
|
|
624
636
|
|
|
625
637
|
const ok = await runBatch(
|
|
626
638
|
resolvedTools,
|
|
627
|
-
(tool) => runScriptPiped(tool.script,
|
|
639
|
+
(tool) => runScriptPiped(tool.script, args),
|
|
628
640
|
{ clear: globalOpts.clear }
|
|
629
641
|
);
|
|
630
642
|
if (!ok) return;
|
|
@@ -643,11 +655,9 @@ Examples:
|
|
|
643
655
|
* Assumes the caller has already ensured the user is logged in.
|
|
644
656
|
* Returns true on success, false on failure.
|
|
645
657
|
*/
|
|
646
|
-
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
|
|
658
|
+
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl } = {}) {
|
|
647
659
|
const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
|
|
648
|
-
|
|
649
|
-
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
650
|
-
if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
660
|
+
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl });
|
|
651
661
|
return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
|
|
652
662
|
}
|
|
653
663
|
|
|
@@ -656,11 +666,9 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
|
|
|
656
666
|
* Caller must ensure the process is running as root.
|
|
657
667
|
* Returns true on success, false on failure.
|
|
658
668
|
*/
|
|
659
|
-
async function runMdmSetupAllBundle(adminApiKey, { backendUrl,
|
|
669
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl } = {}) {
|
|
660
670
|
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)}`;
|
|
671
|
+
const args = buildScriptArgs(adminApiKey, { backendUrl, gatewayUrl, mdm: true });
|
|
664
672
|
return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
|
|
665
673
|
}
|
|
666
674
|
|
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,32 @@ function getFrontendUrl() {
|
|
|
56
87
|
|
|
57
88
|
function setFrontendUrl(url) {
|
|
58
89
|
const config = readConfig();
|
|
59
|
-
config.frontend_url = url;
|
|
90
|
+
config.frontend_url = normalizeUrl(url);
|
|
91
|
+
writeConfig(config);
|
|
92
|
+
return config.frontend_url;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getGatewayUrl() {
|
|
96
|
+
return process.env.UNBOUND_GATEWAY_URL || readConfig().gateway_url || DEFAULT_GATEWAY_URL;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setGatewayUrl(url) {
|
|
100
|
+
const config = readConfig();
|
|
101
|
+
config.gateway_url = normalizeUrl(url);
|
|
102
|
+
writeConfig(config);
|
|
103
|
+
return config.gateway_url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setUrls({ gateway, frontend, backend }) {
|
|
107
|
+
const normalized = {
|
|
108
|
+
gateway_url: normalizeUrl(gateway),
|
|
109
|
+
frontend_url: normalizeUrl(frontend),
|
|
110
|
+
base_url: normalizeUrl(backend),
|
|
111
|
+
};
|
|
112
|
+
const config = readConfig();
|
|
113
|
+
Object.assign(config, normalized);
|
|
60
114
|
writeConfig(config);
|
|
115
|
+
return normalized;
|
|
61
116
|
}
|
|
62
117
|
|
|
63
118
|
function clearConfig() {
|
|
@@ -88,6 +143,10 @@ function backfillUserInfo(apiResponse) {
|
|
|
88
143
|
module.exports = {
|
|
89
144
|
CONFIG_DIR,
|
|
90
145
|
CONFIG_FILE,
|
|
146
|
+
DEFAULT_BASE_URL,
|
|
147
|
+
DEFAULT_FRONTEND_URL,
|
|
148
|
+
DEFAULT_GATEWAY_URL,
|
|
149
|
+
normalizeUrl,
|
|
91
150
|
ensureConfigDir,
|
|
92
151
|
readConfig,
|
|
93
152
|
writeConfig,
|
|
@@ -97,6 +156,9 @@ module.exports = {
|
|
|
97
156
|
setBaseUrl,
|
|
98
157
|
getFrontendUrl,
|
|
99
158
|
setFrontendUrl,
|
|
159
|
+
getGatewayUrl,
|
|
160
|
+
setGatewayUrl,
|
|
161
|
+
setUrls,
|
|
100
162
|
clearConfig,
|
|
101
163
|
isLoggedIn,
|
|
102
164
|
backfillUserInfo,
|
package/src/index.js
CHANGED
|
@@ -32,6 +32,13 @@ 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 — set all three URLs once, then log in with an API key:
|
|
36
|
+
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
37
|
+
$ unbound login --api-key <YOUR_API_KEY>
|
|
38
|
+
Example:
|
|
39
|
+
$ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
|
|
40
|
+
$ unbound login --api-key sk-...
|
|
41
|
+
|
|
35
42
|
ONBOARDING (one-step install + discover)
|
|
36
43
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
37
44
|
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
@@ -73,7 +80,7 @@ MDM SETUP (admin, requires root)
|
|
|
73
80
|
$ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
|
|
74
81
|
|
|
75
82
|
MDM AI TOOLS DISCOVERY
|
|
76
|
-
--domain defaults to
|
|
83
|
+
--domain defaults to the configured backend URL (set via "unbound config set-backend-url")
|
|
77
84
|
$ sudo unbound discover --api-key KEY Scan all users (requires root)
|
|
78
85
|
$ unbound discover --api-key KEY Scan current user only
|
|
79
86
|
$ sudo unbound discover --api-key KEY --domain https://custom.backend.com
|
|
@@ -133,7 +140,20 @@ CHAT (Admin/Manager)
|
|
|
133
140
|
See "unbound chat --help" for the response shape and REPL slash commands.
|
|
134
141
|
|
|
135
142
|
CONFIGURATION
|
|
136
|
-
|
|
143
|
+
Defaults point to api/gateway/backend.getunbound.ai. Tenant deployments override them.
|
|
144
|
+
|
|
145
|
+
One-shot setup (recommended for new tenants):
|
|
146
|
+
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
147
|
+
$ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
|
|
148
|
+
|
|
149
|
+
Per-URL setters (change one host at a time):
|
|
150
|
+
$ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
|
|
151
|
+
$ unbound config set-frontend-url <url> Frontend host (browser login flow)
|
|
152
|
+
$ unbound config set-backend-url <url> Backend REST API host
|
|
153
|
+
|
|
154
|
+
View:
|
|
155
|
+
$ unbound config show All URLs + login state
|
|
156
|
+
$ unbound config show --json Machine-readable
|
|
137
157
|
|
|
138
158
|
FILES
|
|
139
159
|
~/.unbound/config.json Credentials and CLI settings
|
|
@@ -160,52 +180,127 @@ require('./commands/chat').register(program);
|
|
|
160
180
|
// config command for managing CLI settings
|
|
161
181
|
const configCmd = program
|
|
162
182
|
.command('config')
|
|
163
|
-
.description('Manage CLI configuration settings.')
|
|
183
|
+
.description('Manage CLI configuration settings (tenant URLs, view current state).')
|
|
184
|
+
.addHelpText('after', `
|
|
185
|
+
Tenant onboarding (one-shot — recommended):
|
|
186
|
+
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
187
|
+
$ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
|
|
188
|
+
|
|
189
|
+
Per-URL setters (use when changing one host at a time):
|
|
190
|
+
$ unbound config set-gateway-url <url> AI gateway host (used by tool setup)
|
|
191
|
+
$ unbound config set-frontend-url <url> Browser login host
|
|
192
|
+
$ unbound config set-backend-url <url> REST API host
|
|
193
|
+
|
|
194
|
+
View:
|
|
195
|
+
$ unbound config show Print all values, including defaults
|
|
196
|
+
$ unbound config show --json Same, machine-readable
|
|
197
|
+
|
|
198
|
+
Notes:
|
|
199
|
+
- Bare hostnames are accepted (e.g. "api.acme.com" → "https://api.acme.com").
|
|
200
|
+
- Settings persist in ~/.unbound/config.json. Defaults: api/gateway/backend.getunbound.ai.
|
|
201
|
+
- Run "unbound login --api-key <key>" after configuring URLs (no browser needed).
|
|
202
|
+
`);
|
|
164
203
|
|
|
165
|
-
//
|
|
204
|
+
// Reports the value that was actually written to ~/.unbound/config.json,
|
|
205
|
+
// not what the getter would resolve to. The getter applies env-var precedence
|
|
206
|
+
// (UNBOUND_*_URL), which would mislead the user if they had an override set —
|
|
207
|
+
// the message would echo the env var instead of the value they just typed.
|
|
208
|
+
function safeSet(label, fn, url) {
|
|
209
|
+
try {
|
|
210
|
+
const written = fn(url);
|
|
211
|
+
output.success(`${label} set to ${written}`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
output.error(err.message);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Visible: one-shot setter for all three tenant URLs.
|
|
219
|
+
// Positional order: gateway, frontend, backend — same order as the help text and docs.
|
|
166
220
|
configCmd
|
|
167
|
-
.command('
|
|
168
|
-
.description('Set
|
|
169
|
-
.
|
|
170
|
-
|
|
171
|
-
|
|
221
|
+
.command('urls <gateway-url> <frontend-url> <backend-url>')
|
|
222
|
+
.description('Set all three tenant URLs at once (gateway, frontend, backend).')
|
|
223
|
+
.allowExcessArguments(false)
|
|
224
|
+
.addHelpText('after', `
|
|
225
|
+
Use this on a fresh install for tenant deployments. Positional order is fixed:
|
|
226
|
+
1. <gateway-url> — AI gateway host (e.g. https://api.acme.com)
|
|
227
|
+
Wired into tool setup scripts (ANTHROPIC_BASE_URL, codex config, etc.)
|
|
228
|
+
2. <frontend-url> — Frontend host (e.g. https://gateway.acme.com)
|
|
229
|
+
Used by the browser login flow.
|
|
230
|
+
3. <backend-url> — REST API host (e.g. https://backend.acme.com)
|
|
231
|
+
All "unbound *" commands (whoami, status, policies, ...) hit this.
|
|
232
|
+
|
|
233
|
+
Bare hostnames are accepted; "https://" is added automatically.
|
|
234
|
+
The three values are written atomically to ~/.unbound/config.json.
|
|
235
|
+
After running this, log in with: unbound login --api-key <YOUR_API_KEY>
|
|
236
|
+
|
|
237
|
+
Examples:
|
|
238
|
+
$ unbound config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
|
|
239
|
+
$ unbound config urls api.acme.com gateway.acme.com backend.acme.com
|
|
240
|
+
$ unbound config show
|
|
241
|
+
`)
|
|
242
|
+
.action((gateway, frontend, backend) => {
|
|
243
|
+
try {
|
|
244
|
+
const written = config.setUrls({ gateway, frontend, backend });
|
|
245
|
+
output.success('All three tenant URLs set:');
|
|
246
|
+
output.keyValue([
|
|
247
|
+
['Gateway URL', written.gateway_url],
|
|
248
|
+
['Frontend URL', written.frontend_url],
|
|
249
|
+
['Backend URL', written.base_url],
|
|
250
|
+
]);
|
|
251
|
+
console.log('');
|
|
252
|
+
output.info('Next: unbound login --api-key <YOUR_API_KEY>');
|
|
253
|
+
} catch (err) {
|
|
254
|
+
output.error(err.message);
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
}
|
|
172
257
|
});
|
|
173
258
|
|
|
259
|
+
// Per-URL setters (use when changing a single host).
|
|
260
|
+
configCmd
|
|
261
|
+
.command('set-backend-url <url>')
|
|
262
|
+
.description('Set the backend REST API URL (tenant deployments).')
|
|
263
|
+
.action((url) => safeSet('Backend URL', config.setBaseUrl, url));
|
|
264
|
+
|
|
265
|
+
configCmd
|
|
266
|
+
.command('set-frontend-url <url>')
|
|
267
|
+
.description('Set the frontend URL used by the browser login flow.')
|
|
268
|
+
.action((url) => safeSet('Frontend URL', config.setFrontendUrl, url));
|
|
269
|
+
|
|
270
|
+
configCmd
|
|
271
|
+
.command('set-gateway-url <url>')
|
|
272
|
+
.description('Set the AI gateway URL (used by tool setup scripts).')
|
|
273
|
+
.action((url) => safeSet('Gateway URL', config.setGatewayUrl, url));
|
|
274
|
+
|
|
275
|
+
// Hidden back-compat aliases — keep working for existing scripts/users.
|
|
276
|
+
configCmd
|
|
277
|
+
.command('set-url <url>', { hidden: true })
|
|
278
|
+
.description('Alias of set-backend-url (kept for backward compatibility).')
|
|
279
|
+
.action((url) => safeSet('Backend URL', config.setBaseUrl, url));
|
|
280
|
+
|
|
174
281
|
configCmd
|
|
175
282
|
.command('get-url', { hidden: true })
|
|
176
|
-
.description('Show the current
|
|
177
|
-
.action(() => {
|
|
178
|
-
console.log(config.getBaseUrl());
|
|
179
|
-
});
|
|
283
|
+
.description('Show the current backend URL (kept for backward compatibility).')
|
|
284
|
+
.action(() => { console.log(config.getBaseUrl()); });
|
|
180
285
|
|
|
181
286
|
configCmd
|
|
182
287
|
.command('reset-url', { hidden: true })
|
|
183
|
-
.description('Reset the
|
|
288
|
+
.description('Reset the backend URL to default (kept for backward compatibility).')
|
|
184
289
|
.action(() => {
|
|
185
290
|
const cfg = config.readConfig();
|
|
186
291
|
delete cfg.base_url;
|
|
187
292
|
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}`);
|
|
293
|
+
output.success(`Backend URL reset to ${config.getBaseUrl()}`);
|
|
197
294
|
});
|
|
198
295
|
|
|
199
296
|
configCmd
|
|
200
297
|
.command('get-frontend-url', { hidden: true })
|
|
201
|
-
.description('Show the current frontend URL (
|
|
202
|
-
.action(() => {
|
|
203
|
-
console.log(config.getFrontendUrl());
|
|
204
|
-
});
|
|
298
|
+
.description('Show the current frontend URL (kept for backward compatibility).')
|
|
299
|
+
.action(() => { console.log(config.getFrontendUrl()); });
|
|
205
300
|
|
|
206
301
|
configCmd
|
|
207
302
|
.command('reset-frontend-url', { hidden: true })
|
|
208
|
-
.description('Reset the frontend URL to default (
|
|
303
|
+
.description('Reset the frontend URL to default (kept for backward compatibility).')
|
|
209
304
|
.action(() => {
|
|
210
305
|
const cfg = config.readConfig();
|
|
211
306
|
delete cfg.frontend_url;
|
|
@@ -213,6 +308,21 @@ configCmd
|
|
|
213
308
|
output.success(`Frontend URL reset to ${config.getFrontendUrl()}`);
|
|
214
309
|
});
|
|
215
310
|
|
|
311
|
+
configCmd
|
|
312
|
+
.command('get-gateway-url', { hidden: true })
|
|
313
|
+
.description('Show the current gateway URL.')
|
|
314
|
+
.action(() => { console.log(config.getGatewayUrl()); });
|
|
315
|
+
|
|
316
|
+
configCmd
|
|
317
|
+
.command('reset-gateway-url', { hidden: true })
|
|
318
|
+
.description('Reset the gateway URL to default.')
|
|
319
|
+
.action(() => {
|
|
320
|
+
const cfg = config.readConfig();
|
|
321
|
+
delete cfg.gateway_url;
|
|
322
|
+
config.writeConfig(cfg);
|
|
323
|
+
output.success(`Gateway URL reset to ${config.getGatewayUrl()}`);
|
|
324
|
+
});
|
|
325
|
+
|
|
216
326
|
configCmd
|
|
217
327
|
.command('show')
|
|
218
328
|
.description('Show all current configuration values.')
|
|
@@ -223,7 +333,9 @@ configCmd
|
|
|
223
333
|
if (opts.json) {
|
|
224
334
|
output.json({
|
|
225
335
|
config_file: config.CONFIG_FILE,
|
|
226
|
-
|
|
336
|
+
backend_url: config.getBaseUrl(),
|
|
337
|
+
frontend_url: config.getFrontendUrl(),
|
|
338
|
+
gateway_url: config.getGatewayUrl(),
|
|
227
339
|
logged_in: config.isLoggedIn(),
|
|
228
340
|
email: cfg.email || null,
|
|
229
341
|
organization: cfg.org_name || null,
|
|
@@ -233,7 +345,9 @@ configCmd
|
|
|
233
345
|
|
|
234
346
|
output.keyValue([
|
|
235
347
|
['Config file', config.CONFIG_FILE],
|
|
236
|
-
['
|
|
348
|
+
['Backend URL', config.getBaseUrl()],
|
|
349
|
+
['Frontend URL', config.getFrontendUrl()],
|
|
350
|
+
['Gateway URL', config.getGatewayUrl()],
|
|
237
351
|
['Logged in', config.isLoggedIn() ? 'Yes' : 'No'],
|
|
238
352
|
['Email', cfg.email || '-'],
|
|
239
353
|
['Organization', cfg.org_name || '-'],
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { normalizeUrl } = require('../src/config');
|
|
4
|
+
|
|
5
|
+
test('normalizeUrl: bare hostname gets https://', () => {
|
|
6
|
+
assert.equal(normalizeUrl('api.acme.com'), 'https://api.acme.com');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('normalizeUrl: trims whitespace', () => {
|
|
10
|
+
assert.equal(normalizeUrl(' api.acme.com '), 'https://api.acme.com');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('normalizeUrl: strips trailing slash', () => {
|
|
14
|
+
assert.equal(normalizeUrl('https://api.acme.com/'), 'https://api.acme.com');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('normalizeUrl: preserves explicit port', () => {
|
|
18
|
+
assert.equal(normalizeUrl('localhost:8000'), 'https://localhost:8000');
|
|
19
|
+
assert.equal(normalizeUrl('http://localhost:8000'), 'http://localhost:8000');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('normalizeUrl: preserves http scheme when explicit', () => {
|
|
23
|
+
assert.equal(normalizeUrl('http://localhost:3000'), 'http://localhost:3000');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('normalizeUrl: strips path so downstream string-concat is safe', () => {
|
|
27
|
+
assert.equal(
|
|
28
|
+
normalizeUrl('https://gateway.acme.com/login?next=/foo'),
|
|
29
|
+
'https://gateway.acme.com',
|
|
30
|
+
);
|
|
31
|
+
assert.equal(
|
|
32
|
+
normalizeUrl('https://api.acme.com/v1/'),
|
|
33
|
+
'https://api.acme.com',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('normalizeUrl: strips embedded credentials', () => {
|
|
38
|
+
assert.equal(
|
|
39
|
+
normalizeUrl('https://attacker:secret@backend.acme.com'),
|
|
40
|
+
'https://backend.acme.com',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizeUrl: strips fragment', () => {
|
|
45
|
+
assert.equal(normalizeUrl('https://api.acme.com#anything'), 'https://api.acme.com');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('normalizeUrl: rejects non-http(s) scheme', () => {
|
|
49
|
+
// Schemes with `://` are recognized and rejected by scheme check.
|
|
50
|
+
assert.throws(() => normalizeUrl('ftp://x.com'), /http or https/);
|
|
51
|
+
assert.throws(() => normalizeUrl('file:///etc/passwd'), /http or https/);
|
|
52
|
+
// Schemes without `://` (e.g. `javascript:`) get `https://` prepended,
|
|
53
|
+
// then fail URL parsing because the result is malformed.
|
|
54
|
+
assert.throws(() => normalizeUrl('javascript:alert(1)'), /Invalid URL/);
|
|
55
|
+
assert.throws(() => normalizeUrl('data:text/html,foo'), /Invalid URL|http or https/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('normalizeUrl: rejects empty / nullish', () => {
|
|
59
|
+
assert.throws(() => normalizeUrl(''), /must not be empty/);
|
|
60
|
+
assert.throws(() => normalizeUrl(' '), /must not be empty/);
|
|
61
|
+
assert.throws(() => normalizeUrl(null), /required/);
|
|
62
|
+
assert.throws(() => normalizeUrl(undefined), /required/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('normalizeUrl: rejects malformed input', () => {
|
|
66
|
+
assert.throws(() => normalizeUrl('https://'), /Invalid URL|hostname/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Setters must return the value actually written to disk, not what a getter
|
|
70
|
+
// would resolve to (env vars override config in getters and would mislead the
|
|
71
|
+
// user about what was just persisted). Regression test for PR #21 review.
|
|
72
|
+
test('setBaseUrl/setFrontendUrl/setGatewayUrl return the normalized written value', () => {
|
|
73
|
+
const fs = require('node:fs');
|
|
74
|
+
const os = require('node:os');
|
|
75
|
+
const path = require('node:path');
|
|
76
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
|
|
77
|
+
const origHome = process.env.HOME;
|
|
78
|
+
const origGw = process.env.UNBOUND_GATEWAY_URL;
|
|
79
|
+
process.env.HOME = tmp;
|
|
80
|
+
process.env.UNBOUND_GATEWAY_URL = 'https://env-override.example.com';
|
|
81
|
+
// Reload the module fresh so it picks up the temp HOME.
|
|
82
|
+
delete require.cache[require.resolve('../src/config')];
|
|
83
|
+
const c = require('../src/config');
|
|
84
|
+
try {
|
|
85
|
+
assert.equal(c.setBaseUrl('backend.acme.com'), 'https://backend.acme.com');
|
|
86
|
+
assert.equal(c.setFrontendUrl('gateway.acme.com'), 'https://gateway.acme.com');
|
|
87
|
+
// setGatewayUrl returns the WRITTEN value — not what getGatewayUrl() would
|
|
88
|
+
// return (which would be the env-var override).
|
|
89
|
+
assert.equal(c.setGatewayUrl('api.acme.com'), 'https://api.acme.com');
|
|
90
|
+
assert.equal(c.getGatewayUrl(), 'https://env-override.example.com');
|
|
91
|
+
} finally {
|
|
92
|
+
process.env.HOME = origHome;
|
|
93
|
+
if (origGw === undefined) delete process.env.UNBOUND_GATEWAY_URL;
|
|
94
|
+
else process.env.UNBOUND_GATEWAY_URL = origGw;
|
|
95
|
+
delete require.cache[require.resolve('../src/config')];
|
|
96
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|