unbound-cli 0.8.0 → 0.8.2

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.2",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -29,15 +29,18 @@ class ApiError extends Error {
29
29
  }
30
30
  }
31
31
 
32
- function request(method, path, { body, query, apiKey } = {}) {
33
- const baseUrl = config.getBaseUrl();
32
+ function request(method, path, { body, query, apiKey, baseUrl } = {}) {
33
+ // Explicit baseUrl wins over env-var-aware getBaseUrl(). Used by login /
34
+ // setup / onboard so a user's just-passed --backend-url isn't shadowed by a
35
+ // stale UNBOUND_API_URL env var from a prior shell session.
36
+ const resolvedBaseUrl = baseUrl || config.getBaseUrl();
34
37
  const key = apiKey || config.getApiKey();
35
38
 
36
39
  if (!key) {
37
40
  return Promise.reject(new Error('Not logged in. Run `unbound login` first.'));
38
41
  }
39
42
 
40
- const url = new URL(path, baseUrl);
43
+ const url = new URL(path, resolvedBaseUrl);
41
44
  if (query) {
42
45
  for (const [k, v] of Object.entries(query)) {
43
46
  if (v !== undefined && v !== null) {
package/src/auth.js CHANGED
@@ -107,13 +107,17 @@ async function loginWithBrowser(frontendUrl) {
107
107
  * Shows a spinner during validation and a success message with user info.
108
108
  *
109
109
  * @param {string} apiKey - The API key to validate and store
110
+ * @param {object} [opts]
111
+ * @param {string} [opts.baseUrl] - Explicit backend URL override. Used when the
112
+ * caller just persisted a tenant URL via setUrls and wants validation to hit
113
+ * that exact backend, regardless of any stale UNBOUND_API_URL env var.
110
114
  * @returns {Promise<true>}
111
115
  */
112
- async function loginWithApiKey(apiKey) {
116
+ async function loginWithApiKey(apiKey, { baseUrl } = {}) {
113
117
  const spin = output.spinner('Authenticating...');
114
118
  let response;
115
119
  try {
116
- response = await api.get('/api/v1/users/privileges/', { apiKey });
120
+ response = await api.get('/api/v1/users/privileges/', { apiKey, baseUrl });
117
121
  } catch (err) {
118
122
  let msg;
119
123
  if (err.statusCode === 401 || err.statusCode === 403) {
@@ -143,10 +147,14 @@ async function loginWithApiKey(apiKey) {
143
147
  * Ensures the user is logged in. If an apiKey is provided, validates and
144
148
  * stores it first. If not logged in, triggers browser-based login.
145
149
  * Returns true if logged in (or just logged in), false on failure.
150
+ *
151
+ * `baseUrl` and `frontendUrl` are explicit overrides for the URLs to validate
152
+ * against / open in the browser. Used by login/setup/onboard so a just-passed
153
+ * --backend-url / --frontend-url isn't shadowed by a stale env var.
146
154
  */
147
- async function ensureLoggedIn({ apiKey } = {}) {
155
+ async function ensureLoggedIn({ apiKey, baseUrl, frontendUrl } = {}) {
148
156
  if (apiKey) {
149
- await loginWithApiKey(apiKey);
157
+ await loginWithApiKey(apiKey, { baseUrl });
150
158
  return true;
151
159
  }
152
160
 
@@ -155,8 +163,7 @@ async function ensureLoggedIn({ apiKey } = {}) {
155
163
  }
156
164
 
157
165
  output.warn('Not logged in. Opening browser to authenticate...');
158
- const frontendUrl = config.getFrontendUrl();
159
- const result = await loginWithBrowser(frontendUrl);
166
+ const result = await loginWithBrowser(frontendUrl || config.getFrontendUrl());
160
167
 
161
168
  const parts = [];
162
169
  if (result.email) parts.push(`as ${result.email}`);
@@ -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,32 +25,43 @@ 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
-
48
- let apiKey;
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
+ const written = config.setUrls({
51
+ backend: opts.backendUrl || opts.baseUrl,
52
+ frontend: opts.frontendUrl,
53
+ gateway: opts.gatewayUrl,
54
+ });
55
+ // Prefer just-persisted values over env-var-aware getters so a stale
56
+ // UNBOUND_*_URL from a prior shell session can't shadow the user's
57
+ // explicit --backend-url / --frontend-url and route validation at the
58
+ // wrong tenant.
59
+ const explicitBaseUrl = written.base_url;
60
+ const explicitFrontendUrl = written.frontend_url;
49
61
 
50
62
  if (opts.apiKey) {
51
63
  try {
52
- await loginWithApiKey(opts.apiKey);
64
+ await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
53
65
  } catch {
54
66
  process.exitCode = 1;
55
67
  return;
@@ -61,12 +73,13 @@ Examples:
61
73
  if (opts.domain) {
62
74
  frontendUrl = opts.domain.startsWith('http') ? opts.domain : `https://${opts.domain}`;
63
75
  } else {
64
- frontendUrl = config.getFrontendUrl();
76
+ frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
65
77
  }
66
78
  await loginWithBrowser(frontendUrl);
67
- apiKey = config.getApiKey();
68
- // Validate the stored key
69
- await api.get('/api/v1/users/privileges/');
79
+ // Validate the just-stored key against the explicit backend if one
80
+ // was just persisted; otherwise fall back to the env-var-aware
81
+ // getter. api.get reads the stored key from config internally.
82
+ await api.get('/api/v1/users/privileges/', { baseUrl: explicitBaseUrl });
70
83
  }
71
84
 
72
85
  const cfg = config.readConfig();
@@ -50,12 +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
+ let discoveryDomain;
57
54
  try {
58
- await ensureLoggedIn({ apiKey: opts.apiKey });
55
+ // Persist URLs first, then login, then setup — order matters so the
56
+ // login validates against the new backend and setup wires tools at the
57
+ // new gateway. setUrls is atomic; a malformed URL throws before any
58
+ // disk write so the three URLs never end up out of sync.
59
+ const written = config.setUrls({
60
+ backend: opts.backendUrl,
61
+ frontend: opts.frontendUrl,
62
+ gateway: opts.gatewayUrl,
63
+ });
64
+ // Prefer the values we JUST persisted over the env-var-aware getters —
65
+ // a stale UNBOUND_*_URL from a prior shell session could otherwise
66
+ // silently shadow the user's explicit --*-url flag and route login or
67
+ // setup at the wrong tenant.
68
+ const backendUrl = written.base_url || config.getBaseUrl();
69
+ const frontendUrl = written.frontend_url || config.getFrontendUrl();
70
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
71
+ discoveryDomain = opts.domain || backendUrl;
72
+
73
+ await ensureLoggedIn({
74
+ apiKey: opts.apiKey,
75
+ baseUrl: written.base_url,
76
+ frontendUrl: written.frontend_url,
77
+ });
59
78
  const apiKey = config.getApiKey();
60
79
 
61
80
  console.log('');
@@ -111,10 +130,20 @@ Examples:
111
130
  `)
112
131
  .action(async (opts) => {
113
132
  let setupSucceeded = false;
114
- const backendUrl = opts.backendUrl || config.getBaseUrl();
115
- const gatewayUrl = opts.gatewayUrl || config.getGatewayUrl();
116
- const discoveryDomain = opts.domain || backendUrl;
133
+ let discoveryDomain;
117
134
  try {
135
+ // Persist URLs first, then login, then setup — order matters so this
136
+ // MDM run wires tools at the new tenant. Prefer just-written values
137
+ // over env-var-aware getters so a stale UNBOUND_*_URL can't shadow the
138
+ // user's explicit --*-url flag.
139
+ const written = config.setUrls({
140
+ backend: opts.backendUrl,
141
+ gateway: opts.gatewayUrl,
142
+ });
143
+ const backendUrl = written.base_url || config.getBaseUrl();
144
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
145
+ discoveryDomain = opts.domain || backendUrl;
146
+
118
147
  checkRoot('onboard-mdm');
119
148
 
120
149
  console.log('');
@@ -367,11 +367,26 @@ automatically to authenticate before proceeding.
367
367
  `)
368
368
  .action(async (tools, opts) => {
369
369
  try {
370
- await ensureLoggedIn({ apiKey: opts.apiKey });
370
+ // Persist URLs first, login, then setup. setUrls is atomic; a malformed
371
+ // URL throws before any disk write.
372
+ const written = config.setUrls({
373
+ backend: opts.backendUrl,
374
+ frontend: opts.frontendUrl,
375
+ gateway: opts.gatewayUrl,
376
+ });
377
+ // Prefer just-persisted values over env-var-aware getters so a stale
378
+ // UNBOUND_*_URL from a prior shell session can't silently shadow the
379
+ // user's explicit --*-url flag and route login/setup at the wrong tenant.
380
+ const backendUrl = written.base_url || config.getBaseUrl();
381
+ const frontendUrl = written.frontend_url || config.getFrontendUrl();
382
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
383
+
384
+ await ensureLoggedIn({
385
+ apiKey: opts.apiKey,
386
+ baseUrl: written.base_url,
387
+ frontendUrl: written.frontend_url,
388
+ });
371
389
  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();
375
390
  const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
376
391
 
377
392
  // --all expands to the default bundle. Cannot be combined with explicit tool names.
@@ -583,8 +598,16 @@ Examples:
583
598
  // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
584
599
  // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
585
600
  const globalOpts = command.optsWithGlobals();
586
- const backendUrl = globalOpts.backendUrl || config.getBaseUrl();
587
- const gatewayUrl = globalOpts.gatewayUrl || config.getGatewayUrl();
601
+ // Persist URLs first so this MDM run wires tools at the new tenant
602
+ // and any subsequent non-MDM command on the same machine inherits.
603
+ // Prefer just-persisted values over env-var-aware getters so a stale
604
+ // UNBOUND_*_URL can't shadow the explicit --*-url flag.
605
+ const written = config.setUrls({
606
+ backend: globalOpts.backendUrl,
607
+ gateway: globalOpts.gatewayUrl,
608
+ });
609
+ const backendUrl = written.base_url || config.getBaseUrl();
610
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
588
611
 
589
612
  if (globalOpts.all && tools.length > 0) {
590
613
  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,147 @@ 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-4107: setUrls returns the just-written values so callers can use them
155
+ // directly instead of going back through getBaseUrl/etc. (which give env-var
156
+ // precedence over config). A stale UNBOUND_*_URL from a prior shell session
157
+ // would otherwise silently shadow the user's explicit --*-url flag.
158
+ test('setUrls return value reflects what was persisted, bypassing env-var shadowing', () => {
159
+ const fs = require('node:fs');
160
+ const os = require('node:os');
161
+ const path = require('node:path');
162
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
163
+ const origHome = process.env.HOME;
164
+ const origGw = process.env.UNBOUND_GATEWAY_URL;
165
+ const origBe = process.env.UNBOUND_API_URL;
166
+ process.env.HOME = tmp;
167
+ // Stale env vars from a prior tenant — these would otherwise win in getXxxUrl().
168
+ process.env.UNBOUND_GATEWAY_URL = 'https://gw.STALE.com';
169
+ process.env.UNBOUND_API_URL = 'https://be.STALE.com';
170
+ delete require.cache[require.resolve('../src/config')];
171
+ const c = require('../src/config');
172
+ try {
173
+ const written = c.setUrls({
174
+ backend: 'be.NEW.com',
175
+ frontend: 'fe.NEW.com',
176
+ gateway: 'gw.NEW.com',
177
+ });
178
+ // The return value reflects WHAT WAS WRITTEN — the user's explicit input.
179
+ assert.equal(written.base_url, 'https://be.new.com');
180
+ assert.equal(written.frontend_url, 'https://fe.new.com');
181
+ assert.equal(written.gateway_url, 'https://gw.new.com');
182
+ // The getters would still respect the env vars (existing convention).
183
+ // Callers that need the user's intent must use the setUrls return value.
184
+ assert.equal(c.getGatewayUrl(), 'https://gw.STALE.com');
185
+ assert.equal(c.getBaseUrl(), 'https://be.STALE.com');
186
+ } finally {
187
+ process.env.HOME = origHome;
188
+ if (origGw === undefined) delete process.env.UNBOUND_GATEWAY_URL;
189
+ else process.env.UNBOUND_GATEWAY_URL = origGw;
190
+ if (origBe === undefined) delete process.env.UNBOUND_API_URL;
191
+ else process.env.UNBOUND_API_URL = origBe;
192
+ delete require.cache[require.resolve('../src/config')];
193
+ fs.rmSync(tmp, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ // WEB-4103: backfillUserInfo must refresh email/org_name when the API returns
198
+ // a different value (e.g. user switched tenants), not just fill if missing.
199
+ // Defensive: must NOT blank the cached value on a partial/empty API response.
200
+ test('backfillUserInfo refreshes stale email/org but preserves on empty', () => {
201
+ const fs = require('node:fs');
202
+ const os = require('node:os');
203
+ const path = require('node:path');
204
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
205
+ const origHome = process.env.HOME;
206
+ process.env.HOME = tmp;
207
+ delete require.cache[require.resolve('../src/config')];
208
+ const c = require('../src/config');
209
+ try {
210
+ // Seed config as if the user logged in to org1 yesterday.
211
+ c.writeConfig({ api_key: 'sk-x', email: 'user@old.com', org_name: 'OldOrg' });
212
+
213
+ // API now returns the new tenant's identity → cache must update.
214
+ c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
215
+ assert.equal(c.readConfig().email, 'user@new.com');
216
+ assert.equal(c.readConfig().org_name, 'NewOrg');
217
+
218
+ // Subsequent call with same data → no-op (idempotent).
219
+ c.backfillUserInfo({ email: 'user@new.com', org_name: 'NewOrg' });
220
+ assert.equal(c.readConfig().email, 'user@new.com');
221
+
222
+ // Partial / missing API response → cache preserved, NOT blanked.
223
+ c.backfillUserInfo({ email: 'user@new.com' });
224
+ assert.equal(c.readConfig().org_name, 'NewOrg');
225
+ c.backfillUserInfo({});
226
+ assert.equal(c.readConfig().email, 'user@new.com');
227
+ assert.equal(c.readConfig().org_name, 'NewOrg');
228
+
229
+ // Backend returning the legacy `organization_name` key still works.
230
+ c.backfillUserInfo({ organization_name: 'AnotherOrg' });
231
+ assert.equal(c.readConfig().org_name, 'AnotherOrg');
232
+
233
+ // Null / undefined input is a safe no-op.
234
+ c.backfillUserInfo(null);
235
+ c.backfillUserInfo(undefined);
236
+ assert.equal(c.readConfig().email, 'user@new.com');
237
+ } finally {
238
+ process.env.HOME = origHome;
239
+ delete require.cache[require.resolve('../src/config')];
240
+ fs.rmSync(tmp, { recursive: true, force: true });
241
+ }
242
+ });