unbound-cli 1.1.8 → 1.3.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 +10 -10
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/doctor.js +198 -0
- package/src/commands/onboard.js +112 -161
- package/src/commands/setup.js +106 -180
- package/src/commands/status.js +42 -2
- package/src/config.js +1 -1
- package/src/index.js +12 -12
- package/src/toolHealth.js +288 -0
- package/test/command-merge.test.js +44 -0
- package/test/doctor-exit.test.js +31 -0
- package/test/onboard-scope.test.js +114 -0
- package/test/tool-health.test.js +210 -0
- package/src/commands/whoami.js +0 -65
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { statusOf } = require('../src/toolHealth');
|
|
8
|
+
|
|
9
|
+
// --- statusOf rollup (pure): structural artifacts decide install-state; aux
|
|
10
|
+
// wiring only downgrades an installed tool to tampered. ---
|
|
11
|
+
const chk = (kind, ok) => ({ kind, ok });
|
|
12
|
+
|
|
13
|
+
test('statusOf: no structural artifact present → not-installed (dangling env ignored)', () => {
|
|
14
|
+
assert.equal(statusOf([chk('structural', false), chk('structural', false), chk('aux', true)]), 'not-installed');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('statusOf: all structural + all aux present → healthy', () => {
|
|
18
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', true)]), 'healthy');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('statusOf: structural partially present → tampered', () => {
|
|
22
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', false), chk('aux', true)]), 'tampered');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('statusOf: structural complete but wiring (aux) missing → tampered', () => {
|
|
26
|
+
assert.equal(statusOf([chk('structural', true), chk('structural', true), chk('aux', false)]), 'tampered');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// --- detectTools integration: fabricate a tool's on-disk artifacts in a temp
|
|
30
|
+
// HOME and assert the per-tool status. toolHealth reads os.homedir() at load
|
|
31
|
+
// (which honors $HOME on POSIX), so we re-require it under the temp home. ---
|
|
32
|
+
function withHome(fn) {
|
|
33
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-'));
|
|
34
|
+
const origHome = process.env.HOME;
|
|
35
|
+
process.env.HOME = tmp;
|
|
36
|
+
delete require.cache[require.resolve('../src/toolHealth')];
|
|
37
|
+
try {
|
|
38
|
+
return fn(tmp, require('../src/toolHealth'));
|
|
39
|
+
} finally {
|
|
40
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
41
|
+
else process.env.HOME = origHome;
|
|
42
|
+
delete require.cache[require.resolve('../src/toolHealth')];
|
|
43
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeFile(p, content) {
|
|
48
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
49
|
+
fs.writeFileSync(p, content);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('detectTools: cursor with config + script + env → healthy', () => {
|
|
53
|
+
withHome((tmp, th) => {
|
|
54
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
55
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
56
|
+
writeFile(script, '# unbound');
|
|
57
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'k';
|
|
58
|
+
try {
|
|
59
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
60
|
+
assert.equal(cursor.status, 'healthy');
|
|
61
|
+
} finally {
|
|
62
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('detectTools: cursor config + script present but env key blank → tampered (empty != set)', () => {
|
|
68
|
+
withHome((tmp, th) => {
|
|
69
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
70
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
71
|
+
writeFile(script, '# unbound');
|
|
72
|
+
process.env.UNBOUND_CURSOR_API_KEY = ''; // explicitly blanked, not absent
|
|
73
|
+
try {
|
|
74
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
75
|
+
assert.equal(cursor.status, 'tampered');
|
|
76
|
+
} finally {
|
|
77
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('detectTools: cursor env key differs from the configured key → tampered, not healthy', () => {
|
|
83
|
+
withHome((tmp, th) => {
|
|
84
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
85
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
86
|
+
writeFile(script, '# unbound');
|
|
87
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
|
|
88
|
+
try {
|
|
89
|
+
const cursor = th.detectTools({ apiKey: 'current-key' }).find((t) => t.key === 'cursor');
|
|
90
|
+
assert.equal(cursor.status, 'tampered');
|
|
91
|
+
} finally {
|
|
92
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('detectTools: cursor with only the hook script (no config) → tampered', () => {
|
|
98
|
+
withHome((tmp, th) => {
|
|
99
|
+
writeFile(path.join(tmp, '.cursor', 'hooks', 'unbound.py'), '# unbound');
|
|
100
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.key === 'cursor');
|
|
101
|
+
assert.equal(cursor.status, 'tampered');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('detectTools: cursor with a config that lacks the Unbound marker → not-installed', () => {
|
|
106
|
+
withHome((tmp, th) => {
|
|
107
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: {} })); // no unbound.py reference
|
|
108
|
+
const cursor = th.detectTools({ apiKey: 'k' }).find((t) => t.label === 'Cursor');
|
|
109
|
+
assert.equal(cursor.status, 'not-installed');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('detectTools: claude-code collapses to ONE line and reports the installed mode (gateway)', () => {
|
|
114
|
+
withHome((tmp, th) => {
|
|
115
|
+
const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
|
|
116
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ apiKeyHelper: helper }));
|
|
117
|
+
writeFile(helper, 'echo $UNBOUND_API_KEY');
|
|
118
|
+
const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
|
|
119
|
+
assert.equal(claude.length, 1, 'one collapsed claude-code line');
|
|
120
|
+
assert.equal(claude[0].mode, 'gateway');
|
|
121
|
+
assert.notEqual(claude[0].status, 'not-installed');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('detectTools: both claude-code modes present at once → one line, flagged as a conflict', () => {
|
|
126
|
+
withHome((tmp, th) => {
|
|
127
|
+
const helper = path.join(tmp, '.claude', 'anthropic_key.sh');
|
|
128
|
+
// settings.json references BOTH an unbound.py hook AND the gateway key helper.
|
|
129
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({
|
|
130
|
+
hooks: { PreToolUse: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] },
|
|
131
|
+
apiKeyHelper: helper,
|
|
132
|
+
}));
|
|
133
|
+
const claude = th.detectTools({ apiKey: 'k' }).filter((t) => t.family === 'claude-code');
|
|
134
|
+
assert.equal(claude.length, 1, 'still one collapsed line');
|
|
135
|
+
assert.equal(claude[0].conflict, true, 'conflict flagged');
|
|
136
|
+
assert.equal(claude[0].status, 'tampered', 'prefers the tampered variant so --fix cleans it up');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --- MDM (org-managed) scenarios, exercised via the _mdmDirs test override ---
|
|
141
|
+
test('MDM: managed config references unbound + hook present → managed by MDM', () => {
|
|
142
|
+
withHome((tmp, th) => {
|
|
143
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
144
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
145
|
+
writeFile(path.join(mdmDir, 'hooks', 'unbound.py'), '#');
|
|
146
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
147
|
+
assert.equal(t.status, 'managed-by-mdm');
|
|
148
|
+
assert.equal(t.scope, 'mdm');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('MDM: managed config references unbound but hook missing → tampered (mdm scope)', () => {
|
|
153
|
+
withHome((tmp, th) => {
|
|
154
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
155
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
156
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
157
|
+
assert.equal(t.status, 'tampered');
|
|
158
|
+
assert.equal(t.scope, 'mdm');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('MDM: a generic managed-settings.json without an unbound reference → not installed (no false positive)', () => {
|
|
163
|
+
withHome((tmp, th) => {
|
|
164
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
165
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ permissions: { allow: ['*'] } }));
|
|
166
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.label === 'Claude Code');
|
|
167
|
+
assert.equal(t.status, 'not-installed');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('MDM: a user-level install takes precedence over an MDM config', () => {
|
|
172
|
+
withHome((tmp, th) => {
|
|
173
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
174
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
175
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
176
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
177
|
+
process.env.UNBOUND_CLAUDE_API_KEY = 'k';
|
|
178
|
+
try {
|
|
179
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir } }).find((x) => x.family === 'claude-code');
|
|
180
|
+
assert.notEqual(t.scope, 'mdm');
|
|
181
|
+
assert.equal(t.status, 'healthy');
|
|
182
|
+
assert.equal(t.mode, 'subscription');
|
|
183
|
+
} finally { delete process.env.UNBOUND_CLAUDE_API_KEY; }
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('codex subscription: hooks.json + script present but the feature flag is off → tampered', () => {
|
|
188
|
+
withHome((tmp, th) => {
|
|
189
|
+
writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.codex', 'hooks', 'unbound.py') }] } }));
|
|
190
|
+
writeFile(path.join(tmp, '.codex', 'hooks', 'unbound.py'), '#');
|
|
191
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'model = "gpt-5"\n');
|
|
192
|
+
const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
|
|
193
|
+
assert.equal(t.status, 'tampered');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('copilot has no MDM scope — even with a managed dir it never reports managed-by-mdm', () => {
|
|
198
|
+
withHome((tmp, th) => {
|
|
199
|
+
const t = th.detectTools({ apiKey: 'k', _mdmDirs: { copilot: path.join(tmp, 'mdm', 'Copilot') } }).find((x) => x.family === 'copilot');
|
|
200
|
+
assert.equal(t.status, 'not-installed');
|
|
201
|
+
assert.notEqual(t.scope, 'mdm');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('only four tools are reported (Gemini CLI is omitted)', () => {
|
|
206
|
+
withHome((tmp, th) => {
|
|
207
|
+
const keys = th.detectTools({ apiKey: 'k' }).map((t) => t.family);
|
|
208
|
+
assert.deepEqual(new Set(keys), new Set(['cursor', 'claude-code', 'codex', 'copilot']));
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/commands/whoami.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
const config = require('../config');
|
|
2
|
-
const api = require('../api');
|
|
3
|
-
const output = require('../output');
|
|
4
|
-
const { getDeviceSerial } = require('../device-serial');
|
|
5
|
-
|
|
6
|
-
function roleFromPrivileges(privileges) {
|
|
7
|
-
if (privileges.is_admin) return 'Admin';
|
|
8
|
-
if (privileges.is_manager) return 'Manager';
|
|
9
|
-
if (privileges.is_member) return 'Member';
|
|
10
|
-
return 'Unknown';
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function register(program) {
|
|
14
|
-
program
|
|
15
|
-
.command('whoami')
|
|
16
|
-
.description('Display the currently authenticated user, organization, and role. Requires an active login session.')
|
|
17
|
-
.addHelpText('after', `
|
|
18
|
-
Output fields:
|
|
19
|
-
Email - The authenticated user's email address
|
|
20
|
-
Organization - The organization the user belongs to
|
|
21
|
-
Role - One of: Admin, Manager, Member
|
|
22
|
-
|
|
23
|
-
Examples:
|
|
24
|
-
$ unbound whoami
|
|
25
|
-
$ unbound whoami --json
|
|
26
|
-
`)
|
|
27
|
-
.option('--json', 'Output raw JSON')
|
|
28
|
-
.action(async (opts) => {
|
|
29
|
-
if (!config.isLoggedIn()) {
|
|
30
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
31
|
-
process.exitCode = 1;
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const cfg = config.readConfig();
|
|
36
|
-
const spin = output.spinner('Fetching user info...');
|
|
37
|
-
try {
|
|
38
|
-
const deviceSerial = await getDeviceSerial();
|
|
39
|
-
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
40
|
-
query: { device_serial: deviceSerial },
|
|
41
|
-
});
|
|
42
|
-
spin.stop();
|
|
43
|
-
config.backfillUserInfo(privileges);
|
|
44
|
-
|
|
45
|
-
const role = roleFromPrivileges(privileges);
|
|
46
|
-
const freshCfg = config.readConfig();
|
|
47
|
-
|
|
48
|
-
if (opts.json) {
|
|
49
|
-
output.json({ email: freshCfg.email || null, organization: freshCfg.org_name || null, role });
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
output.keyValue([
|
|
54
|
-
['Email', freshCfg.email || '-'],
|
|
55
|
-
['Organization', freshCfg.org_name || '-'],
|
|
56
|
-
['Role', role],
|
|
57
|
-
]);
|
|
58
|
-
} catch (err) {
|
|
59
|
-
spin.fail(err.message);
|
|
60
|
-
process.exitCode = 1;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
module.exports = { register };
|