unbound-cli 0.8.0 → 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/package.json +1 -1
- package/src/commands/login.js +22 -14
- package/src/commands/onboard.js +27 -7
- package/src/commands/setup.js +22 -5
- package/src/config.js +32 -10
- package/src/index.js +10 -5
- package/test/config-normalize-url.test.js +101 -0
package/package.json
CHANGED
package/src/commands/login.js
CHANGED
|
@@ -9,9 +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
|
-
.
|
|
13
|
-
.
|
|
14
|
-
.
|
|
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())
|
|
15
16
|
.option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
|
|
16
17
|
.addHelpText('after', `
|
|
17
18
|
Authentication methods:
|
|
@@ -24,26 +25,33 @@ Authentication methods:
|
|
|
24
25
|
Non-interactive login for CI/CD or automation. Stores the provided
|
|
25
26
|
API key directly without browser interaction.
|
|
26
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
|
+
|
|
27
32
|
Options:
|
|
28
33
|
--domain sets a custom domain for organizations with self-hosted frontends.
|
|
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
|
+
Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
|
|
34
35
|
|
|
35
36
|
Examples:
|
|
36
37
|
$ unbound login # Login via default gateway
|
|
37
|
-
$ unbound login --domain custom.example.com # Login via custom domain
|
|
38
38
|
$ unbound login --api-key sk-abc123 # Non-interactive login
|
|
39
|
-
$ unbound
|
|
40
|
-
|
|
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
|
|
41
44
|
`)
|
|
42
45
|
.action(async (opts) => {
|
|
43
46
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
+
});
|
|
47
55
|
|
|
48
56
|
let apiKey;
|
|
49
57
|
|
package/src/commands/onboard.js
CHANGED
|
@@ -50,11 +50,22 @@ Examples:
|
|
|
50
50
|
`)
|
|
51
51
|
.action(async (opts) => {
|
|
52
52
|
let setupSucceeded = false;
|
|
53
|
-
|
|
54
|
-
const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
|
|
55
|
-
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
56
|
-
const discoveryDomain = opts.domain || backendUrl;
|
|
53
|
+
let discoveryDomain;
|
|
57
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
|
+
|
|
58
69
|
await ensureLoggedIn({ apiKey: opts.apiKey });
|
|
59
70
|
const apiKey = config.getApiKey();
|
|
60
71
|
|
|
@@ -111,10 +122,19 @@ Examples:
|
|
|
111
122
|
`)
|
|
112
123
|
.action(async (opts) => {
|
|
113
124
|
let setupSucceeded = false;
|
|
114
|
-
|
|
115
|
-
const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
|
|
116
|
-
const discoveryDomain = opts.domain || backendUrl;
|
|
125
|
+
let discoveryDomain;
|
|
117
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
|
+
|
|
118
138
|
checkRoot('onboard-mdm');
|
|
119
139
|
|
|
120
140
|
console.log('');
|
package/src/commands/setup.js
CHANGED
|
@@ -367,11 +367,21 @@ automatically to authenticate before proceeding.
|
|
|
367
367
|
`)
|
|
368
368
|
.action(async (tools, opts) => {
|
|
369
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
|
+
|
|
370
380
|
await ensureLoggedIn({ apiKey: opts.apiKey });
|
|
371
381
|
const apiKey = config.getApiKey();
|
|
372
|
-
const backendUrl =
|
|
373
|
-
const frontendUrl =
|
|
374
|
-
const gatewayUrl =
|
|
382
|
+
const backendUrl = config.getBaseUrl();
|
|
383
|
+
const frontendUrl = config.getFrontendUrl();
|
|
384
|
+
const gatewayUrl = config.getGatewayUrl();
|
|
375
385
|
const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
|
|
376
386
|
|
|
377
387
|
// --all expands to the default bundle. Cannot be combined with explicit tool names.
|
|
@@ -583,8 +593,15 @@ Examples:
|
|
|
583
593
|
// --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
|
|
584
594
|
// Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
|
|
585
595
|
const globalOpts = command.optsWithGlobals();
|
|
586
|
-
|
|
587
|
-
|
|
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();
|
|
588
605
|
|
|
589
606
|
if (globalOpts.all && tools.length > 0) {
|
|
590
607
|
output.error('Cannot combine --all with specific tool names. Use one or the other.');
|
package/src/config.js
CHANGED
|
@@ -103,12 +103,24 @@ function setGatewayUrl(url) {
|
|
|
103
103
|
return config.gateway_url;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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;
|
|
112
124
|
const config = readConfig();
|
|
113
125
|
Object.assign(config, normalized);
|
|
114
126
|
writeConfig(config);
|
|
@@ -125,16 +137,26 @@ function isLoggedIn() {
|
|
|
125
137
|
return !!getApiKey();
|
|
126
138
|
}
|
|
127
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
|
+
*/
|
|
128
148
|
function backfillUserInfo(apiResponse) {
|
|
129
149
|
if (!apiResponse) return;
|
|
130
150
|
const cfg = readConfig();
|
|
131
151
|
let changed = false;
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
const apiEmail = apiResponse.email;
|
|
153
|
+
if (apiEmail && cfg.email !== apiEmail) {
|
|
154
|
+
cfg.email = apiEmail;
|
|
134
155
|
changed = true;
|
|
135
156
|
}
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
const apiOrg = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
|
|
158
|
+
if (apiOrg && cfg.org_name !== apiOrg) {
|
|
159
|
+
cfg.org_name = apiOrg;
|
|
138
160
|
changed = true;
|
|
139
161
|
}
|
|
140
162
|
if (changed) writeConfig(cfg);
|
package/src/index.js
CHANGED
|
@@ -32,12 +32,17 @@ 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 —
|
|
36
|
-
$ unbound
|
|
37
|
-
|
|
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
38
|
Example:
|
|
39
|
-
$ unbound
|
|
40
|
-
|
|
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>
|
|
41
46
|
|
|
42
47
|
ONBOARDING (one-step install + discover)
|
|
43
48
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
@@ -96,3 +96,104 @@ test('setBaseUrl/setFrontendUrl/setGatewayUrl return the normalized written valu
|
|
|
96
96
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
97
97
|
}
|
|
98
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
|
+
});
|