unbound-cli 1.16.7 → 1.16.9

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": "1.16.7",
3
+ "version": "1.16.9",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -200,7 +200,7 @@ function register(program) {
200
200
  .addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
201
201
  .addHelpText('after', `
202
202
  Scans this device for installed AI coding tools (Cursor, Claude Code,
203
- Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
203
+ Gemini CLI, Codex, Devin Desktop, Roo Code, Cline, GitHub Copilot, JetBrains,
204
204
  and more) and reports findings to the Unbound backend.
205
205
 
206
206
  Which --api-key to use depends on scope:
@@ -86,12 +86,19 @@ function resolveSetupAllTools(clear) {
86
86
  return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
87
87
  }
88
88
 
89
+ // OpenAI-compatible Base URL: the gateway host with the /v1 path these clients
90
+ // POST to. Strips a trailing slash because getGatewayUrl() returns env/config
91
+ // values unnormalized, which would otherwise double the slash.
92
+ function openAiBaseUrl(gatewayUrl) {
93
+ return `${String(gatewayUrl).replace(/\/+$/, '')}/v1`;
94
+ }
95
+
89
96
  // Instruction-only tools (display config values, no setup scripts)
90
97
  const INSTRUCTION_TOOLS = {
91
98
  'roo-code': { label: 'Roo Code', values: (apiKey) => [['API Provider', 'unbound'], ['API Key', apiKey]] },
92
- 'cline': { label: 'Cline', values: (apiKey, frontendUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', frontendUrl], ['API Key', apiKey]] },
99
+ 'cline': { label: 'Cline', values: (apiKey, gatewayUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', openAiBaseUrl(gatewayUrl)], ['API Key', apiKey]] },
93
100
  'kilo-code': { label: 'Kilo Code', values: (apiKey) => [['API Provider', 'Unbound'], ['API Key', apiKey]] },
94
- 'custom-access': { label: 'Custom Access', values: (apiKey, frontendUrl) => [['API Key', apiKey], ['Base URL', frontendUrl]] },
101
+ 'custom-access': { label: 'Custom Access', values: (apiKey, gatewayUrl) => [['API Key', apiKey], ['Base URL', openAiBaseUrl(gatewayUrl)]] },
95
102
  };
96
103
 
97
104
  // Tools that need interactive mode selection (claude-code, codex without suffix)
@@ -1054,7 +1061,7 @@ must update the MDM configuration.
1054
1061
  if (r && r.mdmSkipped) reportMdmSkips([label]);
1055
1062
  }
1056
1063
  } else if (INSTRUCTION_TOOLS[toolName]) {
1057
- output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
1064
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
1058
1065
  }
1059
1066
  return;
1060
1067
  }
@@ -1110,7 +1117,7 @@ must update the MDM configuration.
1110
1117
  for (const toolName of instructionResults) {
1111
1118
  console.log('');
1112
1119
  output.info(`${INSTRUCTION_TOOLS[toolName].label}:`);
1113
- output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
1120
+ output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
1114
1121
  }
1115
1122
 
1116
1123
  } catch (err) {
@@ -1290,6 +1297,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
1290
1297
  module.exports = {
1291
1298
  register,
1292
1299
  elevated,
1300
+ INSTRUCTION_TOOLS,
1293
1301
  scopeNotice,
1294
1302
  runSetupAllBundle,
1295
1303
  runMdmSetupAllBundle,
@@ -0,0 +1,105 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ const { INSTRUCTION_TOOLS } = require('../src/commands/setup');
6
+
7
+ // Regression: `unbound setup cline` / `custom-access` printed the dashboard/
8
+ // frontend host as the Base URL. OpenAI-compatible clients POST to
9
+ // `<Base URL>/v1/chat/completions`, so the Base URL must be the GATEWAY host
10
+ // (with /v1), never the frontend host.
11
+ const GATEWAY = 'https://api.getunbound.ai';
12
+ const FRONTEND = 'https://gateway.getunbound.ai';
13
+ const API_KEY = 'ub_test_key';
14
+
15
+ function rows(tool, gatewayUrl = GATEWAY) {
16
+ return new Map(INSTRUCTION_TOOLS[tool].values(API_KEY, gatewayUrl));
17
+ }
18
+
19
+ test('cline: Base URL is the gateway /v1 endpoint, provider is OpenAI Compatible', () => {
20
+ const r = rows('cline');
21
+ assert.equal(r.get('Base URL'), `${GATEWAY}/v1`);
22
+ assert.equal(r.get('API Provider'), 'OpenAI Compatible');
23
+ assert.equal(r.get('API Key'), API_KEY);
24
+ });
25
+
26
+ test('custom-access: Base URL is the gateway /v1 endpoint', () => {
27
+ assert.equal(rows('custom-access').get('Base URL'), `${GATEWAY}/v1`);
28
+ });
29
+
30
+ test('a trailing slash on the gateway URL does not produce a double slash', () => {
31
+ // getGatewayUrl() returns env/config values unnormalized, so a trailing slash
32
+ // is reachable in practice.
33
+ for (const tool of ['cline', 'custom-access']) {
34
+ assert.equal(rows(tool, 'https://api.getunbound.ai/').get('Base URL'), `${GATEWAY}/v1`);
35
+ }
36
+ });
37
+
38
+ test('cline / custom-access never print the frontend host as Base URL', () => {
39
+ for (const tool of ['cline', 'custom-access']) {
40
+ for (const [, value] of INSTRUCTION_TOOLS[tool].values(API_KEY, FRONTEND)) {
41
+ assert.notEqual(value, FRONTEND, `${tool} leaked the frontend host as a value`);
42
+ }
43
+ }
44
+ });
45
+
46
+ test('roo-code / kilo-code print no Base URL row', () => {
47
+ for (const tool of ['roo-code', 'kilo-code']) {
48
+ const keys = INSTRUCTION_TOOLS[tool].values(API_KEY, GATEWAY).map(([k]) => k);
49
+ assert.ok(!keys.includes('Base URL'), `${tool} should not print a Base URL`);
50
+ }
51
+ });
52
+
53
+ // ---- Call-site wiring: drive the real `setup` command and confirm it feeds
54
+ // the GATEWAY url (not the frontend url) into the instruction-tool callbacks.
55
+ // The two paths — single-tool and multi-tool — print from separate call sites,
56
+ // so both are exercised. gateway/frontend are deliberately distinct so a
57
+ // regression that reintroduces frontendUrl surfaces as a frontend-host Base URL.
58
+ async function driveSetup(tools) {
59
+ for (const m of ['../src/commands/setup', '../src/auth', '../src/config', '../src/output']) {
60
+ delete require.cache[require.resolve(m)];
61
+ }
62
+ const setup = require('../src/commands/setup');
63
+ const auth = require('../src/auth');
64
+ const config = require('../src/config');
65
+ const output = require('../src/output');
66
+
67
+ const printed = [];
68
+ // hasRootPrivileges() reads process.getuid; stub the uid (not the exported
69
+ // function, which setup.js calls locally) so scope is deterministically
70
+ // non-root and the run reaches the instruction-tool call sites on any host.
71
+ const origGetuid = process.getuid;
72
+ process.getuid = () => 1000;
73
+ auth.ensureLoggedIn = async () => {};
74
+ config.setUrls = () => ({});
75
+ config.getBaseUrl = () => 'https://backend.acme';
76
+ config.getFrontendUrl = () => 'https://frontend.acme';
77
+ config.getGatewayUrl = () => 'https://gateway.acme';
78
+ config.getApiKey = () => 'resolved-key';
79
+ config.isLoggedIn = () => true;
80
+ output.keyValue = (kv) => printed.push(...kv);
81
+ for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
82
+
83
+ const program = new Command();
84
+ program.exitOverride();
85
+ setup.register(program);
86
+ const prevExit = process.exitCode;
87
+ process.exitCode = 0;
88
+ try {
89
+ await program.parseAsync(['node', 'unbound', 'setup', ...tools]);
90
+ } finally {
91
+ process.exitCode = prevExit;
92
+ if (origGetuid) process.getuid = origGetuid; else delete process.getuid;
93
+ }
94
+ return new Map(printed);
95
+ }
96
+
97
+ test('setup cline (single-tool path) prints the gateway /v1 Base URL, not the frontend', async () => {
98
+ const r = await driveSetup(['cline']);
99
+ assert.equal(r.get('Base URL'), 'https://gateway.acme/v1');
100
+ });
101
+
102
+ test('setup cline custom-access (multi-tool path) prints the gateway /v1 Base URL, not the frontend', async () => {
103
+ const r = await driveSetup(['cline', 'custom-access']);
104
+ assert.equal(r.get('Base URL'), 'https://gateway.acme/v1');
105
+ });