unbound-cli 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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).
@@ -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));