subto 8.0.3 → 8.0.4

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.
@@ -101,7 +101,10 @@ async function postScan(url, apiKey) {
101
101
  }
102
102
  const fetchFn = global.fetch || (function(){ try{ const u = require('undici'); if (u && typeof u.fetch === 'function') { global.fetch = u.fetch; return global.fetch; } } catch(e){} return null; })();
103
103
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Install Node 18+ or run `npm install undici`.');
104
- const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` }, body: JSON.stringify(body) });
104
+
105
+ // Send both Authorization and x-api-key headers to maximize compatibility across deployments
106
+ const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'x-api-key': String(apiKey), 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` };
107
+ const res = await fetchFn(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
105
108
  const text = await res.text();
106
109
  let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
107
110
  return { status: res.status, headers: res.headers, body: data };
@@ -212,8 +215,9 @@ async function run(argv) {
212
215
 
213
216
  let recheckedAfterTerminal = false;
214
217
  while (true) {
215
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
216
- const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
218
+ const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
219
+ // Include both Authorization and x-api-key headers so the server accepts either format
220
+ const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
217
221
 
218
222
  if (r.status === 429) {
219
223
  let retrySeconds = null;
package/index.js CHANGED
@@ -11,7 +11,7 @@ const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
11
11
  const CONFIG_DIR = path.join(os.homedir(), '.subto');
12
12
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
13
13
  const DEFAULT_API_BASE = 'https://subto.one/api/v1';
14
- const CLIENT_META = { name: 'subto-cli', version: '8.0.3' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '8.0.4' };
15
15
  const cp = require('child_process');
16
16
 
17
17
  // Normalize SUBTO API base so callers can set either
@@ -138,9 +138,9 @@ async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
138
138
  // Best-effort validation
139
139
  let validated = false;
140
140
  const fetchFn = global.fetch;
141
- if (typeof fetchFn === 'function') {
142
- try {
143
- const res = await fetchFn('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${key}` } });
141
+ if (typeof fetchFn === 'function') {
142
+ try {
143
+ const res = await fetchWithKey('https://openrouter.ai/api/v1/models', { headers: { } }, key);
144
144
  if (res && res.ok) {
145
145
  const jd = await res.json().catch(()=>null);
146
146
  const modelsList = jd && (jd.models || jd.data || jd) ;
@@ -199,30 +199,84 @@ async function promptHidden(prompt) {
199
199
  function validateUrl(input) {
200
200
  try { const u = new URL(input); if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; return true; } catch (err) { return false; }
201
201
  }
202
+ async function _maskHeaders(obj){
203
+ const out = {};
204
+ try{
205
+ for (const k of Object.keys(obj||{})){
206
+ let v = obj[k];
207
+ if (v === undefined || v === null) v = '';
208
+ let sv = String(v);
209
+ if (/authorization/i.test(k) || /api-?key/i.test(k)){
210
+ if (sv.length > 12) sv = sv.slice(0,6) + '...' + sv.slice(-4); else sv = '***';
211
+ }
212
+ out[k] = sv;
213
+ }
214
+ }catch(e){}
215
+ return out;
216
+ }
202
217
 
203
- async function postScan(url, apiKey) {
218
+ async function postScan(url, apiKey, debug=false) {
204
219
  // Use normalized API base
205
220
  const endpoint = new URL('scan', SUBTO_API_BASE_SLASH).toString();
206
221
  const body = { url, source: 'cli', client: CLIENT_META };
207
- const fetchFn = global.fetch;
208
- if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
222
+ const fetchFn = global.fetch;
223
+ if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
209
224
  // Validate header-safety to avoid undici/node fetch ByteString conversion errors
210
225
  if (!isByteStringSafe(String(apiKey || ''))) {
211
226
  throw new Error('API key contains unsupported characters (non-Latin-1). Re-run `subto login` and paste a plain ASCII API key.');
212
227
  }
213
- const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` }, body: JSON.stringify(body) });
228
+
229
+ // Send both Authorization and x-api-key headers to maximize compatibility across deployments
230
+ const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'x-api-key': String(apiKey), 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` };
231
+ if (debug) {
232
+ try { console.error(chalk.dim('Request headers:'), JSON.stringify(await _maskHeaders(headers))); } catch(e){}
233
+ }
234
+ const res = await fetchFn(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
214
235
  const text = await res.text();
215
236
  let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
237
+ if (debug) {
238
+ try {
239
+ const hdrs = {};
240
+ if (res && res.headers && typeof res.headers.forEach === 'function') res.headers.forEach((v,k)=>{ hdrs[k]=v; });
241
+ console.error(chalk.dim('Response status:'), res.status);
242
+ console.error(chalk.dim('Response headers:'), JSON.stringify(await _maskHeaders(hdrs)));
243
+ console.error(chalk.dim('Response body:'), String(text).slice(0,4096));
244
+ } catch(e){}
245
+ }
216
246
  return { status: res.status, headers: res.headers, body: data };
217
247
  }
218
248
 
249
+ // Helper to fetch with both Authorization and x-api-key headers and optional debug logging
250
+ async function fetchWithKey(url, opts, apiKey, debug=false){
251
+ const fetchFn = global.fetch;
252
+ if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
253
+ const baseHeaders = (opts && opts.headers) ? Object.assign({}, opts.headers) : {};
254
+ const headers = Object.assign({}, baseHeaders, { 'Authorization': `Bearer ${apiKey}`, 'x-api-key': String(apiKey) });
255
+ if (debug) {
256
+ try { console.error(chalk.dim('Request headers:'), JSON.stringify(await _maskHeaders(headers))); } catch(e){}
257
+ }
258
+ const r = await fetchFn(url, Object.assign({}, opts || {}, { headers }));
259
+ if (debug) {
260
+ try {
261
+ const txt = await r.text();
262
+ const hdrs = {}; if (r && r.headers && typeof r.headers.forEach === 'function') r.headers.forEach((v,k)=>hdrs[k]=v);
263
+ console.error(chalk.dim('Response status:'), r.status);
264
+ console.error(chalk.dim('Response headers:'), JSON.stringify(await _maskHeaders(hdrs)));
265
+ console.error(chalk.dim('Response body:'), String(txt).slice(0,4096));
266
+ // put body back so callers expecting .json() will not fail
267
+ r._cached_text = txt;
268
+ } catch(e){}
269
+ }
270
+ return r;
271
+ }
272
+
219
273
  function extractScanIdFromHtml(html) {
220
274
  if (!html || typeof html !== 'string') return null;
221
275
  // common patterns: /scan/ID or scanId="..." or data-scan-id="..."
222
276
  const m1 = html.match(/\/scan\/(?:id\/)?([a-zA-Z0-9_-]{8,})/i);
223
277
  if (m1 && m1[1]) return m1[1];
224
278
  const m2 = html.match(/scanId["'\s:=]+([a-zA-Z0-9_-]{8,})/i);
225
- if (m2 && m2[1]) return m2[1];
279
+ program.command('scan <url>')
226
280
  const m3 = html.match(/data-scan-id["'\s=:\>]+([a-zA-Z0-9_-]{8,})/i);
227
281
  if (m3 && m3[1]) return m3[1];
228
282
  return null;
@@ -375,9 +429,7 @@ async function callOpenAI(prompt){
375
429
  } catch (e) { /* ignore config read errors */ }
376
430
  const fetchFn = global.fetch;
377
431
  if(typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
378
-
379
- // If OpenAI key present, call OpenAI API; otherwise, if OpenRouter key present, call OpenRouter endpoint.
380
- if (openaiKey) {
432
+
381
433
  const model = process.env.AI_MODEL || process.env.OPENAI_MODEL || 'gpt-4o-mini';
382
434
  const body = {
383
435
  model,
@@ -519,8 +571,9 @@ async function startChatREPL(scanData){
519
571
 
520
572
  async function run(argv) {
521
573
  const program = new Command();
522
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.3');
574
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.4');
523
575
  program.option('-v, --verbose', 'Show verbose HTTP logs');
576
+ program.option('--debug', 'Show debug HTTP headers and responses');
524
577
  program.option('--chat', 'Start local AI assistant (no command required)');
525
578
  program.option('--no-auto-skip', 'Disable automatic skipping of external APIs when scans appear stuck');
526
579
  program.option('--skip-prompt-ms <n>', 'Milliseconds before prompting to skip external APIs (default 15000)');
@@ -555,7 +608,8 @@ async function run(argv) {
555
608
  if (!validateUrl(url)) { console.error(chalk.red('Invalid URL. Provide a full URL including http:// or https://')); process.exit(1); }
556
609
  const cfg = await readConfig(); if (!cfg || !cfg.apiKey) { console.error(chalk.red('Missing API key. Run:'), chalk.cyan('subto login')); process.exit(1); }
557
610
  try {
558
- const resp = await postScan(url, cfg.apiKey);
611
+ const DEBUG = (program && typeof program.opts === 'function') ? Boolean(program.opts().debug) : false;
612
+ const resp = await postScan(url, cfg.apiKey, DEBUG);
559
613
  if (resp.status === 429) {
560
614
  let retrySeconds = null;
561
615
  if (resp.body && typeof resp.body === 'object' && resp.body.retry_after) retrySeconds = resp.body.retry_after;
@@ -585,7 +639,7 @@ async function run(argv) {
585
639
  if (attemptId && typeof fetchFn === 'function') {
586
640
  try {
587
641
  const statusUrl = new URL(`scan/${attemptId}`, SUBTO_API_BASE_SLASH).toString();
588
- const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
642
+ const r2 = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
589
643
  if (r2 && r2.ok) {
590
644
  try { resp.body = await r2.json(); } catch (e) { /* leave as-is */ }
591
645
  } else {
@@ -837,7 +891,7 @@ async function run(argv) {
837
891
  startSpinner();
838
892
  while (true) {
839
893
  const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
840
- const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
894
+ const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
841
895
 
842
896
  if (r.status === 429) {
843
897
  let retrySeconds = null;
@@ -885,7 +939,7 @@ async function run(argv) {
885
939
  console.log(chalk.yellow('\nNo progress detected on external APIs — requesting server to skip external API calls (auto).'));
886
940
  const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
887
941
  markSkipRequested(lastServerPercent);
888
- await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
942
+ await fetchWithKey(skipUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, cfg.apiKey, DEBUG);
889
943
  console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
890
944
  } else {
891
945
  console.log(chalk.yellow('\nNo progress detected on external APIs. Automatic skip is disabled.'));
@@ -935,7 +989,7 @@ async function run(argv) {
935
989
  console.log(chalk.yellow('\nSkip requested by user.'));
936
990
  const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
937
991
  markSkipRequested(serverPct);
938
- await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
992
+ await fetchWithKey(skipUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, cfg.apiKey, DEBUG);
939
993
  console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
940
994
  } catch (e) {
941
995
  console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
@@ -975,7 +1029,7 @@ async function run(argv) {
975
1029
  console.log(chalk.yellow('\nAuto-skip timeout reached — requesting server to skip external API calls.'));
976
1030
  const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
977
1031
  markSkipRequested(lastServerPercent);
978
- await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
1032
+ await fetchWithKey(skipUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, cfg.apiKey, DEBUG);
979
1033
  console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
980
1034
  } else {
981
1035
  console.log(chalk.yellow('\nAuto-skip timeout reached but automatic skip is disabled. Continuing to wait.'));
@@ -1017,7 +1071,7 @@ async function run(argv) {
1017
1071
  console.log(chalk.yellow('\nScan stuck at 50% for too long — auto-requesting skip of external APIs.'));
1018
1072
  const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
1019
1073
  markSkipRequested(lastServerPercent);
1020
- await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
1074
+ await fetchWithKey(skipUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, cfg.apiKey, DEBUG);
1021
1075
  console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
1022
1076
  } else {
1023
1077
  console.log(chalk.yellow('\nScan stuck at 50% for too long but automatic skip is disabled.'));
@@ -1042,7 +1096,7 @@ async function run(argv) {
1042
1096
  // give the server a moment to populate results
1043
1097
  await sleep(2000);
1044
1098
  // continue to refresh once more
1045
- try { const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } }); data = await r2.json(); } catch (e) {}
1099
+ try { const r2 = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG); data = await r2.json(); } catch (e) {}
1046
1100
  // final render
1047
1101
  renderLine(data);
1048
1102
  }
@@ -1097,7 +1151,7 @@ async function run(argv) {
1097
1151
  const endpoint = new URL('/api/v1/upload', base).toString();
1098
1152
  const fetchFn = global.fetch; if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1099
1153
  const body = { files: samples, meta: { collected: collected.length, totalBytes } };
1100
- const r = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}` }, body: JSON.stringify(body) });
1154
+ const r = await fetchWithKey(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, cfg.apiKey, DEBUG);
1101
1155
  if (!r.ok) {
1102
1156
  const txt = await r.text().catch(()=>null);
1103
1157
  console.error(chalk.red('Upload failed:'), r.status, txt || r.statusText);
@@ -1115,7 +1169,7 @@ async function run(argv) {
1115
1169
  const statusUrl = new URL(`scan/${j.scanId}`, SUBTO_API_BASE_SLASH).toString();
1116
1170
  const sleep = ms => new Promise(r=>setTimeout(r,ms));
1117
1171
  while (true) {
1118
- const s = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
1172
+ const s = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1119
1173
  if (!s.ok) { console.error(chalk.red('Failed to fetch scan status:', s.status)); break; }
1120
1174
  const data = await s.json(); if (data.status && ['completed','done','finished','success'].includes(String(data.status).toLowerCase())) { console.log(chalk.green('Scan complete.')); await printFullReport(data); break; }
1121
1175
  console.log(chalk.dim('Scan status:'), data.status || 'queued', '— polling again in 4s'); await sleep(4000);
@@ -1147,7 +1201,7 @@ async function run(argv) {
1147
1201
  const fetchFn = global.fetch;
1148
1202
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1149
1203
  const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
1150
- const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
1204
+ const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1151
1205
  if (!r.ok) { throw new Error(`Failed to fetch scan ${scanId}: ${r.status}`); }
1152
1206
  scanData = await r.json();
1153
1207
  }
@@ -1364,7 +1418,7 @@ async function run(argv) {
1364
1418
  const base = SUBTO_HOST_BASE; const fetchFn = global.fetch;
1365
1419
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1366
1420
  const statusUrl = new URL(`scan/${answer}`, SUBTO_API_BASE_SLASH).toString();
1367
- const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
1421
+ const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1368
1422
  if (!r.ok) throw new Error(`Failed to fetch scan ${answer}: ${r.status}`);
1369
1423
  scanData = await r.json();
1370
1424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "8.0.3",
3
+ "version": "8.0.4",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"