subto 8.0.6 → 9.0.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.
@@ -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';
14
- const CLIENT_META = { name: 'subto-cli', version: '8.0.3' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '9.0.1' };
15
15
 
16
16
  function configFilePath() { return CONFIG_PATH; }
17
17
 
@@ -122,9 +122,52 @@ function printScanSummary(obj) {
122
122
  if (keys.length) console.log(chalk.dim(' Additional keys: ' + keys.join(', ')));
123
123
  }
124
124
 
125
+ async function fetchWithKey(url, opts, apiKey) {
126
+ 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; })();
127
+ if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Install Node 18+ or run `npm install undici`.');
128
+ const baseHeaders = (opts && opts.headers) ? Object.assign({}, opts.headers) : {};
129
+ const headers = Object.assign({}, baseHeaders, { 'Authorization': `Bearer ${apiKey}`, 'x-api-key': String(apiKey) });
130
+ return fetchFn(url, Object.assign({}, opts || {}, { headers }));
131
+ }
132
+
133
+ async function fetchJsonWithKey(url, opts, apiKey) {
134
+ const res = await fetchWithKey(url, opts, apiKey);
135
+ const text = await res.text();
136
+ let data = null;
137
+ try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
138
+ return { status: res.status, headers: res.headers, body: data };
139
+ }
140
+
141
+ function formatCount(value) {
142
+ const numeric = Number(value);
143
+ if (!Number.isFinite(numeric)) return '0';
144
+ return numeric.toLocaleString('en-US');
145
+ }
146
+
147
+ function formatMemberSince(value) {
148
+ if (!value) return 'Unknown';
149
+ const parsed = new Date(value);
150
+ if (Number.isNaN(parsed.getTime())) return String(value);
151
+ return parsed.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
152
+ }
153
+
154
+ function printAccountSummary(payload) {
155
+ const account = payload && payload.account ? payload.account : {};
156
+ const stats = account && account.stats ? account.stats : {};
157
+ const title = account.name || payload.accountId || payload.userId || 'Account';
158
+
159
+ console.log(chalk.bold('Account summary:'));
160
+ console.log(chalk.green(' Name:') + ' ' + title);
161
+ if (account.email) console.log(chalk.green(' Email:') + ' ' + account.email);
162
+ if (payload.accountId) console.log(chalk.green(' Account ID:') + ' ' + payload.accountId);
163
+ console.log(chalk.green(' API calls:') + ' ' + formatCount(stats.apiCalls));
164
+ console.log(chalk.green(' Scans:') + ' ' + formatCount(stats.scans));
165
+ console.log(chalk.green(' Member since:') + ' ' + formatMemberSince(account.createdAt));
166
+ }
167
+
125
168
  async function run(argv) {
126
169
  const program = new Command();
127
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '7.0.0');
170
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '9.0.1');
128
171
 
129
172
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
130
173
  try {
@@ -143,6 +186,45 @@ async function run(argv) {
143
186
  } catch (err) { if (err && err.message === 'Aborted') { console.log('\n' + chalk.yellow('Login aborted.')); process.exit(1); } throw err; }
144
187
  });
145
188
 
189
+ program
190
+ .command('account')
191
+ .description('Show the current account name, API calls, and scans')
192
+ .option('--json', 'Output raw JSON')
193
+ .action(async (opts) => {
194
+ const cfg = await readConfig();
195
+ if (!cfg || !cfg.apiKey) {
196
+ console.error(chalk.red('Missing API key. Run:'), chalk.cyan('subto login'));
197
+ process.exit(1);
198
+ }
199
+
200
+ try {
201
+ const endpoint = new URL('/api/v1/account/me', process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).toString();
202
+ const resp = await fetchJsonWithKey(endpoint, { headers: { 'Accept': 'application/json' } }, cfg.apiKey);
203
+
204
+ if (resp.status === 401 || resp.status === 403) {
205
+ console.error(chalk.red('Authentication failed. Check your API key with `subto login`.'));
206
+ process.exit(1);
207
+ }
208
+ if (resp.status < 200 || resp.status >= 300) {
209
+ let msg = `Request failed with status ${resp.status}`;
210
+ if (resp.body && typeof resp.body === 'object' && resp.body.error) msg += ': ' + resp.body.error;
211
+ console.error(chalk.red(msg));
212
+ process.exit(1);
213
+ }
214
+
215
+ if (opts.json) {
216
+ console.log(JSON.stringify(resp.body, null, 2));
217
+ return;
218
+ }
219
+
220
+ printAccountSummary(resp.body);
221
+ } catch (err) {
222
+ const msg = err && err.message ? err.message : String(err);
223
+ console.error(chalk.red('Network error:'), msg);
224
+ process.exit(1);
225
+ }
226
+ });
227
+
146
228
  program
147
229
  .command('scan <url>')
148
230
  .description('Request a scan for <url> via the Subto API')
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.6' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '9.0.1' };
15
15
  const cp = require('child_process');
16
16
 
17
17
  // Normalize SUBTO API base so callers can set either
@@ -217,7 +217,10 @@ async function _maskHeaders(obj){
217
217
 
218
218
  async function postScan(url, apiKey, debug=false) {
219
219
  // Use normalized API base
220
- const endpoint = new URL('scan', SUBTO_API_BASE_SLASH).toString();
220
+ const endpointObj = new URL('scan', SUBTO_API_BASE_SLASH);
221
+ // API key is sent via headers only — never in query strings to prevent
222
+ // leakage via server logs, proxy logs, and Referer headers.
223
+ const endpoint = endpointObj.toString();
221
224
  const body = { url, source: 'cli', client: CLIENT_META };
222
225
  const fetchFn = global.fetch;
223
226
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
@@ -293,6 +296,41 @@ function printScanSummary(obj) {
293
296
  if (keys.length) console.log(chalk.dim(' Additional keys: ' + keys.join(', ')));
294
297
  }
295
298
 
299
+ async function fetchJsonWithKey(url, opts, apiKey, debug=false) {
300
+ const res = await fetchWithKey(url, opts, apiKey, debug);
301
+ const text = typeof res._cached_text === 'string' ? res._cached_text : await res.text();
302
+ let data = null;
303
+ try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
304
+ return { status: res.status, headers: res.headers, body: data };
305
+ }
306
+
307
+ function formatCount(value) {
308
+ const numeric = Number(value);
309
+ if (!Number.isFinite(numeric)) return '0';
310
+ return numeric.toLocaleString('en-US');
311
+ }
312
+
313
+ function formatMemberSince(value) {
314
+ if (!value) return 'Unknown';
315
+ const parsed = new Date(value);
316
+ if (Number.isNaN(parsed.getTime())) return String(value);
317
+ return parsed.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
318
+ }
319
+
320
+ function printAccountSummary(payload) {
321
+ const account = payload && payload.account ? payload.account : {};
322
+ const stats = account && account.stats ? account.stats : {};
323
+ const title = account.name || payload.accountId || payload.userId || 'Account';
324
+
325
+ console.log(chalk.bold('Account summary:'));
326
+ console.log(chalk.green(' Name:') + ' ' + title);
327
+ if (account.email) console.log(chalk.green(' Email:') + ' ' + account.email);
328
+ if (payload.accountId) console.log(chalk.green(' Account ID:') + ' ' + payload.accountId);
329
+ console.log(chalk.green(' API calls:') + ' ' + formatCount(stats.apiCalls));
330
+ console.log(chalk.green(' Scans:') + ' ' + formatCount(stats.scans));
331
+ console.log(chalk.green(' Member since:') + ' ' + formatMemberSince(account.createdAt));
332
+ }
333
+
296
334
  async function printFullReport(data) {
297
335
  const scan = (data && data.results) ? data.results : data;
298
336
  if (!scan || typeof scan !== 'object') { console.log(JSON.stringify(data, null, 2)); return; }
@@ -398,12 +436,12 @@ async function printFullReport(data) {
398
436
  function copyToClipboard(text){
399
437
  try{
400
438
  if (process.platform === 'darwin'){
401
- const p = cp.spawnSync('pbcopy');
402
- p.stdin && p.stdin.end(String(text));
439
+ cp.spawnSync('pbcopy', { input: String(text) });
403
440
  return true;
404
441
  }
405
442
  if (process.platform === 'win32'){
406
- const p = cp.spawnSync('clip'); p.stdin && p.stdin.end(String(text)); return true;
443
+ cp.spawnSync('clip', { input: String(text) });
444
+ return true;
407
445
  }
408
446
  }catch(e){}
409
447
  return false;
@@ -429,6 +467,8 @@ async function callOpenAI(prompt){
429
467
  const fetchFn = global.fetch;
430
468
  if(typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
431
469
 
470
+ // Try OpenAI first if key is available
471
+ if (openaiKey) {
432
472
  const model = process.env.AI_MODEL || process.env.OPENAI_MODEL || 'gpt-4o-mini';
433
473
  const body = {
434
474
  model,
@@ -444,9 +484,11 @@ async function callOpenAI(prompt){
444
484
  // Avoid including response bodies in errors to prevent accidental leakage of sensitive data
445
485
  throw new Error(`OpenAI API error ${res.status}: ${res.statusText || 'request failed'}`);
446
486
  }
447
- const j = await res.json();
487
+ let j;
488
+ try { j = await res.json(); } catch (_) { throw new Error('OpenAI returned non-JSON response'); }
448
489
  const msg = j && j.choices && j.choices[0] && (j.choices[0].message && j.choices[0].message.content || j.choices[0].text);
449
490
  return String(msg || '').trim();
491
+ }
450
492
 
451
493
  if (openrouterKey) {
452
494
  // Use OpenRouter chat completions endpoint
@@ -465,7 +507,8 @@ async function callOpenAI(prompt){
465
507
  // Avoid including response bodies in errors to prevent accidental leakage of sensitive data
466
508
  throw new Error(`OpenRouter API error ${res.status}: ${res.statusText || 'request failed'}`);
467
509
  }
468
- const j = await res.json();
510
+ let j;
511
+ try { j = await res.json(); } catch (_) { throw new Error('OpenRouter returned non-JSON response'); }
469
512
  // OpenRouter responses mimic OpenAI shape (choices[0].message.content)
470
513
  const msg = j && j.choices && j.choices[0] && (j.choices[0].message && j.choices[0].message.content || j.choices[0].text);
471
514
  return String(msg || '').trim();
@@ -569,7 +612,7 @@ async function startChatREPL(scanData){
569
612
 
570
613
  async function run(argv) {
571
614
  const program = new Command();
572
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.6');
615
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '9.0.1');
573
616
  program.option('-v, --verbose', 'Show verbose HTTP logs');
574
617
  program.option('--debug', 'Show debug HTTP headers and responses');
575
618
  program.option('--chat', 'Start local AI assistant (no command required)');
@@ -595,6 +638,46 @@ async function run(argv) {
595
638
  } catch (err) { if (err && err.message === 'Aborted') { console.log('\n' + chalk.yellow('Login aborted.')); process.exit(1); } throw err; }
596
639
  });
597
640
 
641
+ program
642
+ .command('account')
643
+ .description('Show the current account name, API calls, and scans')
644
+ .option('--json', 'Output raw JSON')
645
+ .action(async (opts) => {
646
+ const cfg = await readConfig();
647
+ if (!cfg || !cfg.apiKey) {
648
+ console.error(chalk.red('Missing API key. Run:'), chalk.cyan('subto login'));
649
+ process.exit(1);
650
+ }
651
+
652
+ try {
653
+ const DEBUG = (program && typeof program.opts === 'function') ? Boolean(program.opts().debug) : false;
654
+ const endpoint = new URL('account/me', SUBTO_API_BASE_SLASH).toString();
655
+ const resp = await fetchJsonWithKey(endpoint, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
656
+
657
+ if (resp.status === 401 || resp.status === 403) {
658
+ console.error(chalk.red('Authentication failed. Check your API key with `subto login`.'));
659
+ process.exit(1);
660
+ }
661
+ if (resp.status < 200 || resp.status >= 300) {
662
+ let msg = `Request failed with status ${resp.status}`;
663
+ if (resp.body && typeof resp.body === 'object' && resp.body.error) msg += ': ' + resp.body.error;
664
+ console.error(chalk.red(msg));
665
+ process.exit(1);
666
+ }
667
+
668
+ if (opts.json) {
669
+ console.log(JSON.stringify(resp.body, null, 2));
670
+ return;
671
+ }
672
+
673
+ printAccountSummary(resp.body);
674
+ } catch (err) {
675
+ const msg = err && err.message ? err.message : String(err);
676
+ console.error(chalk.red('Network error:'), msg);
677
+ process.exit(1);
678
+ }
679
+ });
680
+
598
681
  program
599
682
  .command('scan <url>')
600
683
  .description('Request a scan for <url> via the Subto API')
@@ -887,7 +970,14 @@ async function run(argv) {
887
970
  let recheckedAfterTerminal = false;
888
971
  let data = null;
889
972
  startSpinner();
973
+ const MAX_POLL_ITERATIONS = 900; // ~30 min at 2s interval
974
+ let pollIteration = 0;
890
975
  while (true) {
976
+ if (++pollIteration > MAX_POLL_ITERATIONS) {
977
+ stopSpinner();
978
+ console.error(chalk.red('Polling timed out after ' + MAX_POLL_ITERATIONS + ' iterations. The scan may still be running on the server.'));
979
+ process.exit(1);
980
+ }
891
981
  const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
892
982
  const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
893
983
 
@@ -1149,7 +1239,7 @@ async function run(argv) {
1149
1239
  const endpoint = new URL('/api/v1/upload', base).toString();
1150
1240
  const fetchFn = global.fetch; if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1151
1241
  const body = { files: samples, meta: { collected: collected.length, totalBytes } };
1152
- const r = await fetchWithKey(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, cfg.apiKey, DEBUG);
1242
+ const r = await fetchWithKey(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }, cfg.apiKey, Boolean(program.opts && program.opts().debug));
1153
1243
  if (!r.ok) {
1154
1244
  const txt = await r.text().catch(()=>null);
1155
1245
  console.error(chalk.red('Upload failed:'), r.status, txt || r.statusText);
@@ -1161,13 +1251,14 @@ async function run(argv) {
1161
1251
  } catch (e) { /* ignore */ }
1162
1252
  process.exit(1);
1163
1253
  }
1164
- const j = await r.json(); console.log(chalk.green('Upload queued:'), j.uploadId, 'scanId:', j.scanId, 'expiresAt:', new Date(j.expiresAt).toString());
1254
+ let j; try { j = await r.json(); } catch (_) { console.error(chalk.red('Server returned non-JSON response')); process.exit(1); }
1255
+ console.log(chalk.green('Upload queued:'), j.uploadId, 'scanId:', j.scanId, 'expiresAt:', j.expiresAt ? new Date(j.expiresAt).toString() : 'N/A');
1165
1256
  if (opts.wait) {
1166
1257
  // Poll the scan resource until completed (reuse existing polling behavior)
1167
1258
  const statusUrl = new URL(`scan/${j.scanId}`, SUBTO_API_BASE_SLASH).toString();
1168
1259
  const sleep = ms => new Promise(r=>setTimeout(r,ms));
1169
1260
  while (true) {
1170
- const s = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1261
+ const s = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, Boolean(program.opts && program.opts().debug));
1171
1262
  if (!s.ok) { console.error(chalk.red('Failed to fetch scan status:', s.status)); break; }
1172
1263
  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; }
1173
1264
  console.log(chalk.dim('Scan status:'), data.status || 'queued', '— polling again in 4s'); await sleep(4000);
@@ -1201,7 +1292,7 @@ async function run(argv) {
1201
1292
  const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
1202
1293
  const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1203
1294
  if (!r.ok) { throw new Error(`Failed to fetch scan ${scanId}: ${r.status}`); }
1204
- scanData = await r.json();
1295
+ try { scanData = await r.json(); } catch (_) { throw new Error('Server returned non-JSON response for scan ' + scanId); }
1205
1296
  }
1206
1297
 
1207
1298
  if (!scanData) { console.error(chalk.red('No scan data available.')); return; }
@@ -1378,7 +1469,7 @@ async function run(argv) {
1378
1469
  const endpoint = new URL(`video/${scanId}/debug`, serverBase).toString();
1379
1470
  const res = await fetchFn(endpoint, { headers: { 'Accept': 'application/json' } });
1380
1471
  if (!res.ok) throw new Error(`Server returned ${res.status}`);
1381
- const json = await res.json();
1472
+ let json; try { json = await res.json(); } catch (_) { throw new Error('Server returned non-JSON response'); }
1382
1473
  console.log(chalk.bold('\nVideo diagnostic for:'), scanId);
1383
1474
  if (json && json.scan) {
1384
1475
  console.log(' URL:', json.scan.url || '(unknown)');
@@ -1416,9 +1507,9 @@ async function run(argv) {
1416
1507
  const base = SUBTO_HOST_BASE; const fetchFn = global.fetch;
1417
1508
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1418
1509
  const statusUrl = new URL(`scan/${answer}`, SUBTO_API_BASE_SLASH).toString();
1419
- const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, DEBUG);
1510
+ const r = await fetchWithKey(statusUrl, { headers: { 'Accept': 'application/json' } }, cfg.apiKey, false);
1420
1511
  if (!r.ok) throw new Error(`Failed to fetch scan ${answer}: ${r.status}`);
1421
- scanData = await r.json();
1512
+ try { scanData = await r.json(); } catch (_) { throw new Error('Server returned non-JSON response'); }
1422
1513
  }
1423
1514
  await startChatREPL(scanData);
1424
1515
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "8.0.6",
3
+ "version": "9.0.1",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"