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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
- .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())
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 config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com \\
40
- && unbound login --api-key sk-abc123 # Tenant onboarding
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
- if (opts.baseUrl) config.setBaseUrl(opts.baseUrl);
45
- if (opts.frontendUrl) config.setFrontendUrl(opts.frontendUrl);
46
- if (opts.gatewayUrl) config.setGatewayUrl(opts.gatewayUrl);
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
 
@@ -50,11 +50,22 @@ 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
+ 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
- const backendUrl = opts.backendUrl || config.getBaseUrl();
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('');
@@ -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 = opts.backendUrl || config.getBaseUrl();
373
- const frontendUrl = opts.frontendUrl || config.getFrontendUrl();
374
- const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
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
- const backendUrl = globalOpts.backendUrl || config.getBaseUrl();
587
- const gatewayUrl = globalOpts.gatewayUrl || config.getGatewayUrl();
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
- function setUrls({ gateway, frontend, backend }) {
107
- const normalized = {
108
- gateway_url: normalizeUrl(gateway),
109
- frontend_url: normalizeUrl(frontend),
110
- base_url: normalizeUrl(backend),
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
- if (!cfg.email && apiResponse.email) {
133
- cfg.email = apiResponse.email;
152
+ const apiEmail = apiResponse.email;
153
+ if (apiEmail && cfg.email !== apiEmail) {
154
+ cfg.email = apiEmail;
134
155
  changed = true;
135
156
  }
136
- if (!cfg.org_name && (apiResponse.org_name || apiResponse.organization_name || apiResponse.organization)) {
137
- cfg.org_name = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
157
+ const apiOrg = apiResponse.org_name || apiResponse.organization_name || apiResponse.organization;
158
+ if (apiOrg && cfg.org_name !== apiOrg) {
159
+ cfg.org_name = apiOrg;
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 — 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>
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 config urls https://api.acme.com https://gateway.acme.com https://backend.acme.com
40
- $ unbound login --api-key sk-...
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
+ });