unbound-cli 0.6.0 → 0.7.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/discover.js +94 -0
- package/src/commands/setup.js +132 -6
- package/src/index.js +9 -0
package/package.json
CHANGED
package/src/commands/discover.js
CHANGED
|
@@ -2,17 +2,46 @@ const { spawn, execSync } = require('child_process');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
5
|
+
const https = require('https');
|
|
5
6
|
const output = require('../output');
|
|
6
7
|
|
|
7
8
|
const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
|
|
8
9
|
const DEFAULT_DOMAIN = 'https://backend.getunbound.ai';
|
|
9
10
|
const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
|
|
10
11
|
|
|
12
|
+
// Native Windows (cmd/PowerShell) takes the install.ps1 path below. WSL reports
|
|
13
|
+
// as Linux via process.platform and keeps using the existing bash install.sh pipe.
|
|
14
|
+
function isWindowsNative() {
|
|
15
|
+
return process.platform === 'win32';
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
function isRoot() {
|
|
12
19
|
if (process.platform === 'win32') return false;
|
|
13
20
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
function downloadToFile(url, destPath) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const request = (u, remaining) => {
|
|
26
|
+
https.get(u, (res) => {
|
|
27
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
28
|
+
res.resume();
|
|
29
|
+
return request(res.headers.location, remaining - 1);
|
|
30
|
+
}
|
|
31
|
+
if (res.statusCode !== 200) {
|
|
32
|
+
res.resume();
|
|
33
|
+
return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
|
|
34
|
+
}
|
|
35
|
+
const file = fs.createWriteStream(destPath);
|
|
36
|
+
res.pipe(file);
|
|
37
|
+
file.on('finish', () => file.close(resolve));
|
|
38
|
+
file.on('error', reject);
|
|
39
|
+
}).on('error', reject);
|
|
40
|
+
};
|
|
41
|
+
request(url, 3);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
16
45
|
/**
|
|
17
46
|
* Escapes a string for safe embedding in a shell command (single-quote wrap).
|
|
18
47
|
* Mirrors the helper in setup.js. Required because runDiscoveryScript uses
|
|
@@ -25,8 +54,16 @@ function shellEscape(str) {
|
|
|
25
54
|
/**
|
|
26
55
|
* Downloads a bash script from the discovery repo and executes it with arguments.
|
|
27
56
|
* Uses stdio: 'inherit' so the script's output is shown live.
|
|
57
|
+
*
|
|
58
|
+
* On native Windows, downloads `install.ps1` from the same repo and executes
|
|
59
|
+
* it via PowerShell instead. `scriptName` is mapped from install.sh →
|
|
60
|
+
* install.ps1 automatically; any other bash-only script (e.g.
|
|
61
|
+
* setup-scheduled-scan.sh) remains unsupported on native Windows.
|
|
28
62
|
*/
|
|
29
63
|
function runDiscoveryScript(scriptName, args) {
|
|
64
|
+
if (isWindowsNative()) {
|
|
65
|
+
return runDiscoveryScriptWindows(scriptName, args);
|
|
66
|
+
}
|
|
30
67
|
return new Promise((resolve, reject) => {
|
|
31
68
|
const url = `${DISCOVER_BASE_URL}/${scriptName}`;
|
|
32
69
|
const cmd = `curl -fsSL "${url}" | bash -s -- ${args}`;
|
|
@@ -45,6 +82,63 @@ function runDiscoveryScript(scriptName, args) {
|
|
|
45
82
|
});
|
|
46
83
|
}
|
|
47
84
|
|
|
85
|
+
// Parse the shell-escaped args string back to argv for the Windows path.
|
|
86
|
+
function parsePosixArgs(s) {
|
|
87
|
+
const out = [];
|
|
88
|
+
let cur = '', inQ = false, i = 0;
|
|
89
|
+
while (i < s.length) {
|
|
90
|
+
const c = s[i];
|
|
91
|
+
if (inQ) {
|
|
92
|
+
if (c === "'" && s.substr(i, 4) === "'\\''") { cur += "'"; i += 4; continue; }
|
|
93
|
+
if (c === "'") { inQ = false; i++; continue; }
|
|
94
|
+
cur += c; i++;
|
|
95
|
+
} else {
|
|
96
|
+
if (c === ' ') { if (cur) { out.push(cur); cur = ''; } i++; continue; }
|
|
97
|
+
if (c === "'") { inQ = true; i++; continue; }
|
|
98
|
+
cur += c; i++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (cur) out.push(cur);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function runDiscoveryScriptWindows(scriptName, args) {
|
|
106
|
+
if (scriptName !== 'install.sh') {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`"${scriptName}" is not supported on native Windows. ` +
|
|
109
|
+
`Run this command under WSL, or on macOS/Linux.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const winScript = 'install.ps1';
|
|
113
|
+
const url = `${DISCOVER_BASE_URL}/${winScript}`;
|
|
114
|
+
const tmp = path.join(os.tmpdir(), `unbound-discover-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
|
|
115
|
+
try {
|
|
116
|
+
await downloadToFile(url, tmp);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${err.message}. Native Windows support requires ${winScript} in the coding-discovery-tool repo.`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// Map posix flags to PowerShell-style parameter names (install.ps1's convention).
|
|
123
|
+
const argv = parsePosixArgs(args).map((a) => a === '--api-key' ? '-ApiKey' : a === '--domain' ? '-Domain' : a);
|
|
124
|
+
try {
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
const child = spawn(
|
|
127
|
+
'powershell',
|
|
128
|
+
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmp, ...argv],
|
|
129
|
+
{ stdio: 'inherit', shell: false, windowsHide: true }
|
|
130
|
+
);
|
|
131
|
+
child.on('close', (code) => {
|
|
132
|
+
if (code === 0) resolve();
|
|
133
|
+
else reject(new Error(`Discovery script failed with exit code ${code}`));
|
|
134
|
+
});
|
|
135
|
+
child.on('error', reject);
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
48
142
|
/**
|
|
49
143
|
* Runs the main discovery scan (install.sh) with the given discovery key and domain.
|
|
50
144
|
* Emits a warning if not running as root (scan will be limited to the current user).
|
package/src/commands/setup.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
const { execSync, spawn } = require('child_process');
|
|
1
|
+
const { execSync, spawn, spawnSync } = require('child_process');
|
|
2
2
|
const { Option } = require('commander');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const https = require('https');
|
|
3
7
|
const config = require('../config');
|
|
4
8
|
const output = require('../output');
|
|
5
9
|
const { ensureLoggedIn } = require('../auth');
|
|
6
10
|
|
|
7
11
|
const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
|
|
8
12
|
|
|
13
|
+
// WSL reports as Linux via uname; only native Windows (cmd.exe / PowerShell)
|
|
14
|
+
// takes the Windows code path. WSL keeps using the Linux curl|python3 pipe.
|
|
15
|
+
function isWindowsNative() {
|
|
16
|
+
return process.platform === 'win32';
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
const SETUP_TOOLS = [
|
|
10
20
|
{ label: 'Cursor', value: 'cursor', script: 'cursor/setup.py' },
|
|
11
21
|
{ label: 'Claude Code \u2014 subscription (hooks)', value: 'claude-sub', script: 'claude-code/hooks/setup.py', group: 'claude-code' },
|
|
@@ -81,21 +91,134 @@ function shellEscape(str) {
|
|
|
81
91
|
|
|
82
92
|
/**
|
|
83
93
|
* Builds a shell command that curls a setup script and pipes it to python3.
|
|
94
|
+
* Used on macOS/Linux/WSL. Native Windows takes the runPythonScriptWindows path.
|
|
84
95
|
*/
|
|
85
96
|
function buildSetupCommand(scriptPath, args) {
|
|
86
97
|
const url = `${SETUP_BASE_URL}/${scriptPath}`;
|
|
87
98
|
return `curl -fsSL "${url}" | python3 - ${args}`;
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Reverses shellEscape back into an argv list so the Windows path can call
|
|
103
|
+
* spawn() directly without going through a shell. Handles unquoted tokens and
|
|
104
|
+
* single-quoted strings with '\'' for embedded quotes (the exact format
|
|
105
|
+
* shellEscape produces).
|
|
106
|
+
*/
|
|
107
|
+
function parsePosixArgs(s) {
|
|
108
|
+
const out = [];
|
|
109
|
+
let cur = '', inQ = false, i = 0;
|
|
110
|
+
while (i < s.length) {
|
|
111
|
+
const c = s[i];
|
|
112
|
+
if (inQ) {
|
|
113
|
+
if (c === "'" && s.substr(i, 4) === "'\\''") { cur += "'"; i += 4; continue; }
|
|
114
|
+
if (c === "'") { inQ = false; i++; continue; }
|
|
115
|
+
cur += c; i++;
|
|
116
|
+
} else {
|
|
117
|
+
if (c === ' ') { if (cur) { out.push(cur); cur = ''; } i++; continue; }
|
|
118
|
+
if (c === "'") { inQ = true; i++; continue; }
|
|
119
|
+
cur += c; i++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (cur) out.push(cur);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Downloads a URL to a local file. Follows one level of redirect (GitHub raw
|
|
128
|
+
* occasionally 302s). Used only by the Windows execution path.
|
|
129
|
+
*/
|
|
130
|
+
function downloadToFile(url, destPath) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const request = (u, remaining) => {
|
|
133
|
+
https.get(u, (res) => {
|
|
134
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
|
|
135
|
+
res.resume();
|
|
136
|
+
return request(res.headers.location, remaining - 1);
|
|
137
|
+
}
|
|
138
|
+
if (res.statusCode !== 200) {
|
|
139
|
+
res.resume();
|
|
140
|
+
return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
|
|
141
|
+
}
|
|
142
|
+
const file = fs.createWriteStream(destPath);
|
|
143
|
+
res.pipe(file);
|
|
144
|
+
file.on('finish', () => file.close(resolve));
|
|
145
|
+
file.on('error', reject);
|
|
146
|
+
}).on('error', reject);
|
|
147
|
+
};
|
|
148
|
+
request(url, 3);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolves the Python launcher to use on native Windows.
|
|
154
|
+
* Prefers the `py` launcher (installed by python.org at C:\Windows\py.exe —
|
|
155
|
+
* no spaces in PATH), falls back to `python` on PATH.
|
|
156
|
+
*/
|
|
157
|
+
function resolveWindowsPython() {
|
|
158
|
+
const probe = (cmd, args) => {
|
|
159
|
+
try {
|
|
160
|
+
const r = spawnSync(cmd, args, { stdio: 'ignore', shell: false, windowsHide: true });
|
|
161
|
+
return r.status === 0;
|
|
162
|
+
} catch { return false; }
|
|
163
|
+
};
|
|
164
|
+
if (probe('py', ['-3', '--version'])) return { cmd: 'py', prefix: ['-3'] };
|
|
165
|
+
if (probe('python', ['--version'])) return { cmd: 'python', prefix: [] };
|
|
166
|
+
if (probe('python3', ['--version'])) return { cmd: 'python3', prefix: [] };
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Python 3 not found. Install Python 3 from https://www.python.org/downloads/ ' +
|
|
169
|
+
'and make sure the "Add python.exe to PATH" option is checked during install.'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Windows counterpart to the curl|python3 pipe. Downloads the script to a
|
|
175
|
+
* temp file and invokes Python directly — no shell required.
|
|
176
|
+
*/
|
|
177
|
+
async function runPythonScriptWindows(scriptPath, args, { capture }) {
|
|
178
|
+
const url = `${SETUP_BASE_URL}/${scriptPath}`;
|
|
179
|
+
const tmp = path.join(os.tmpdir(), `unbound-${Date.now()}-${Math.random().toString(36).slice(2)}.py`);
|
|
180
|
+
await downloadToFile(url, tmp);
|
|
181
|
+
const py = resolveWindowsPython();
|
|
182
|
+
try {
|
|
183
|
+
await new Promise((resolve, reject) => {
|
|
184
|
+
const child = spawn(py.cmd, [...py.prefix, tmp, ...parsePosixArgs(args)], {
|
|
185
|
+
stdio: capture ? ['pipe', 'pipe', 'pipe'] : 'inherit',
|
|
186
|
+
shell: false,
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
});
|
|
189
|
+
let out = '';
|
|
190
|
+
if (capture) {
|
|
191
|
+
child.stdin.on('error', () => {});
|
|
192
|
+
child.stdin.end();
|
|
193
|
+
child.stdout.on('data', (d) => { out += d.toString(); });
|
|
194
|
+
child.stderr.on('data', (d) => { out += d.toString(); });
|
|
195
|
+
}
|
|
196
|
+
child.on('close', (code) => {
|
|
197
|
+
if (code === 0) return resolve();
|
|
198
|
+
const err = new Error(out.trim() || `Setup script failed with exit code ${code}`);
|
|
199
|
+
if (capture) err.setupOutput = out.trim();
|
|
200
|
+
reject(err);
|
|
201
|
+
});
|
|
202
|
+
child.on('error', reject);
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
90
209
|
/**
|
|
91
210
|
* Runs a Python setup script from the setup repo with inherited stdio (live output).
|
|
92
211
|
*/
|
|
93
|
-
function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
|
|
212
|
+
async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
|
|
94
213
|
let args = `--api-key ${shellEscape(apiKey)}`;
|
|
95
214
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
96
215
|
if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
97
216
|
if (clear) args += ' --clear';
|
|
98
217
|
console.log('');
|
|
218
|
+
if (isWindowsNative()) {
|
|
219
|
+
await runPythonScriptWindows(scriptPath, args, { capture: false });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
99
222
|
try {
|
|
100
223
|
execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
|
|
101
224
|
} catch (err) {
|
|
@@ -108,6 +231,9 @@ function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, fronten
|
|
|
108
231
|
* Returns a promise that resolves on success, rejects with captured output on failure.
|
|
109
232
|
*/
|
|
110
233
|
function runScriptPiped(scriptPath, args) {
|
|
234
|
+
if (isWindowsNative()) {
|
|
235
|
+
return runPythonScriptWindows(scriptPath, args, { capture: true });
|
|
236
|
+
}
|
|
111
237
|
return new Promise((resolve, reject) => {
|
|
112
238
|
const child = spawn(buildSetupCommand(scriptPath, args), {
|
|
113
239
|
shell: true,
|
|
@@ -326,13 +452,13 @@ automatically to authenticate before proceeding.
|
|
|
326
452
|
const toolName = tools[0];
|
|
327
453
|
|
|
328
454
|
if (SETUP_TOOL_MAP[toolName]) {
|
|
329
|
-
runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
455
|
+
await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
330
456
|
} else if (MODE_TOOLS[toolName]) {
|
|
331
457
|
const mode = MODE_TOOLS[toolName];
|
|
332
458
|
if (opts.clear) {
|
|
333
459
|
// Clear both modes
|
|
334
|
-
runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
335
|
-
runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
460
|
+
await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
461
|
+
await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
336
462
|
} else {
|
|
337
463
|
let useSubscription = opts.subscription;
|
|
338
464
|
if (!opts.subscription && !opts.gateway) {
|
|
@@ -340,7 +466,7 @@ automatically to authenticate before proceeding.
|
|
|
340
466
|
useSubscription = choice === 'subscription';
|
|
341
467
|
}
|
|
342
468
|
const resolved = useSubscription ? mode.subscription : mode.gateway;
|
|
343
|
-
runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
469
|
+
await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
344
470
|
}
|
|
345
471
|
} else if (INSTRUCTION_TOOLS[toolName]) {
|
|
346
472
|
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
// Force UTF-8 stdio in every subprocess the CLI spawns (directly or
|
|
4
|
+
// transitively through bash / PowerShell / Python). Windows Python defaults
|
|
5
|
+
// to cp1252, which UnicodeEncodeError's on the ✅/❌ prints in setup scripts
|
|
6
|
+
// and aborts onboard mid-flight. Setting on process.env applies universally —
|
|
7
|
+
// Node's child_process APIs inherit process.env by default, so every current
|
|
8
|
+
// and future command is covered without touching call sites.
|
|
9
|
+
process.env.PYTHONIOENCODING = process.env.PYTHONIOENCODING || 'utf-8';
|
|
10
|
+
process.env.PYTHONUTF8 = process.env.PYTHONUTF8 || '1';
|
|
11
|
+
|
|
3
12
|
const { Command } = require('commander');
|
|
4
13
|
const config = require('./config');
|
|
5
14
|
const output = require('./output');
|