subto 3.0.2 → 4.0.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.
@@ -40,7 +40,7 @@ Commands
40
40
  {
41
41
  "url": "https://example.com",
42
42
  "source": "cli",
43
- "client": { "name": "subto-cli", "version": "3.0.2" }
43
+ "client": { "name": "subto-cli", "version": "4.0.0" }
44
44
  }
45
45
  ```
46
46
 
@@ -98,11 +98,11 @@ Download
98
98
  After publishing or packing, a distributable tarball will be available under `./dist/` e.g.:
99
99
 
100
100
  ```
101
- ./dist/subto-3.0.2.tgz
101
+ ./dist/subto-4.0.0.tgz
102
102
  ```
103
103
 
104
104
  You can download that file directly and install locally with:
105
105
 
106
106
  ```bash
107
- npm install -g ./dist/subto-3.0.2.tgz
107
+ npm install -g ./dist/subto-4.0.0.tgz
108
108
  ```
@@ -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: '3.0.2' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '4.0.0' };
15
15
 
16
16
  function configFilePath() { return CONFIG_PATH; }
17
17
 
@@ -91,7 +91,7 @@ function printScanSummary(obj) {
91
91
 
92
92
  async function run(argv) {
93
93
  const program = new Command();
94
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '3.0.2');
94
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '4.0.0');
95
95
 
96
96
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
97
97
  try {
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';
14
- const CLIENT_META = { name: 'subto-cli', version: '3.0.2' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '4.0.0' };
15
15
 
16
16
  function configFilePath() { return CONFIG_PATH; }
17
17
 
@@ -89,9 +89,94 @@ function printScanSummary(obj) {
89
89
  if (keys.length) console.log(chalk.dim(' Additional keys: ' + keys.join(', ')));
90
90
  }
91
91
 
92
+ function printFullReport(data) {
93
+ const scan = (data && data.results) ? data.results : data;
94
+ if (!scan || typeof scan !== 'object') { console.log(JSON.stringify(data, null, 2)); return; }
95
+
96
+ const lh = scan.lighthouse || scan.lhr || (scan.results && scan.results.lighthouse) || null;
97
+ const vt = scan.virustotal || (scan.results && scan.results.virustotal) || null;
98
+ const timings = scan.timings || scan.coreWebVitals || (scan.results && scan.results.timings) || null;
99
+
100
+ console.log(chalk.bold.underline('\nOverview'));
101
+ if (scan.scanId || scan.id) console.log(chalk.green(' ID:'), scan.scanId || scan.id);
102
+ if (scan.url) console.log(chalk.green(' URL:'), scan.url);
103
+ if (scan.status) console.log(chalk.green(' Status:'), scan.status);
104
+ if (scan.completedAt) console.log(chalk.green(' Completed:'), new Date(scan.completedAt).toString());
105
+
106
+ console.log(chalk.bold.underline('\nSystem Analysis'));
107
+ if (lh) console.log(' Performance:', lh.performance ?? lh.performanceScore ?? '--');
108
+ if (lh) console.log(' Accessibility:', lh.accessibility ?? lh.accessibilityScore ?? '--');
109
+ if (lh) console.log(' SEO:', lh.seo ?? lh.seoScore ?? '--');
110
+ if (lh) console.log(' Best Practices:', lh.bestPractices ?? lh.bestPracticesScore ?? '--');
111
+ if (scan.jsDependency || scan.jsDependencyPercent) console.log(' JS Dependency:', scan.jsDependencyPercent ?? scan.jsDependency ?? '--');
112
+ if (scan.securityGrade) console.log(' Security:', scan.securityGrade);
113
+
114
+ if (timings) {
115
+ console.log(chalk.bold.underline('\nCore Web Vitals'));
116
+ const fcp = timings.firstContentfulPaint || timings.fcp || timings.FirstContentfulPaint;
117
+ const lcp = timings.largestContentfulPaint || timings.lcp || timings.LargestContentfulPaint;
118
+ const cls = timings.cumulativeLayoutShift || timings.cls;
119
+ const tti = timings.timeToInteractive || timings.tti;
120
+ const si = timings.speedIndex || timings.SpeedIndex;
121
+ if (fcp !== undefined) console.log(' First Contentful Paint:', String(fcp));
122
+ if (lcp !== undefined) console.log(' Largest Contentful Paint:', String(lcp));
123
+ if (cls !== undefined) console.log(' Cumulative Layout Shift:', String(cls));
124
+ if (tti !== undefined) console.log(' Time to Interactive:', String(tti));
125
+ if (si !== undefined) console.log(' Speed Index:', String(si));
126
+ }
127
+
128
+ // Execution Map / Dependencies heuristics
129
+ console.log(chalk.bold.underline('\nExecution Map'));
130
+ const listCandidates = [];
131
+ ['scripts','resources','dependencies','files','sourceFiles','assets','externalScripts'].forEach(k => { if (scan[k] && Array.isArray(scan[k])) listCandidates.push({k,arr:scan[k]}); if (scan.results && scan.results[k] && Array.isArray(scan.results[k])) listCandidates.push({k,arr:scan.results[k]}); });
132
+ if (listCandidates.length === 0) {
133
+ // try to guess from validations
134
+ const jsPaths = scan.validations && scan.validations.javascript && scan.validations.javascript.scripts;
135
+ if (Array.isArray(jsPaths)) listCandidates.push({k:'javascript files', arr: jsPaths});
136
+ }
137
+ if (listCandidates.length) {
138
+ for (const c of listCandidates.slice(0,3)) {
139
+ console.log(chalk.dim(` ${c.k}:`));
140
+ for (const item of c.arr.slice(0,25)) console.log(' -', (typeof item === 'string') ? item : JSON.stringify(item));
141
+ }
142
+ } else {
143
+ console.log(' (no dependency list available in scan payload)');
144
+ }
145
+
146
+ // Issues summary
147
+ console.log(chalk.bold.underline('\nIssues'));
148
+ if (Array.isArray(scan.issues) && scan.issues.length) {
149
+ const byImpact = scan.issues.slice(0,50);
150
+ for (const it of byImpact) console.log(' -', it.title || it.message || JSON.stringify(it).slice(0,120));
151
+ } else if (scan.issuesCount) {
152
+ console.log(' Issues Count:', scan.issuesCount);
153
+ } else {
154
+ console.log(' No issues reported');
155
+ }
156
+
157
+ // Malware / VirusTotal
158
+ if (vt) {
159
+ console.log(chalk.bold.underline('\nMalware Scan (VirusTotal)'));
160
+ if (vt.scanDate) console.log(' Scan Date:', vt.scanDate);
161
+ console.log(' Harmless:', vt.harmless ?? vt.undetected ?? '--');
162
+ console.log(' Malicious:', vt.malicious ?? '--');
163
+ console.log(' Suspicious:', vt.suspicious ?? '--');
164
+ if (vt.resultUrl) console.log(' View Full VirusTotal Report:', vt.resultUrl.replace('https://www.virustotal.com/gui/url/','https://www.virustotal.com/gui/url/'));
165
+ }
166
+
167
+ // Video link
168
+ if (scan.videoUrl || scan.videoPath || scan.hasVideo) {
169
+ const vurl = scan.videoUrl || ((scan.videoPath) ? `${process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE}/video/${scan.scanId || scan.id}` : null);
170
+ if (vurl) console.log(chalk.bold('\nSession video:'), chalk.cyan(vurl));
171
+ }
172
+
173
+ // AI assistant hint
174
+ console.log(chalk.dim('\nTo inspect full JSON output, run with --json'));
175
+ }
176
+
92
177
  async function run(argv) {
93
178
  const program = new Command();
94
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '3.0.2');
179
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '4.0.0');
95
180
 
96
181
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
97
182
  try {
@@ -151,9 +236,153 @@ async function run(argv) {
151
236
 
152
237
  const sleep = ms => new Promise(r => setTimeout(r, ms));
153
238
  let interval = 5000;
154
- console.log(chalk.blue('Queued scan. Polling for progress...'));
239
+ const unicodeFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
240
+ const asciiFrames = ['|','/','-','\\'];
241
+ const spinnerFrames = (process.stdout && process.stdout.isTTY) ? unicodeFrames : asciiFrames;
242
+ let spinnerIndex = 0;
243
+ let spinnerTimer = null;
244
+ // Percent smoothing: last displayed percent and target from server
245
+ let lastDisplayedPercent = 0;
246
+ let targetPercent = 0;
247
+ let lastPollTs = Date.now();
248
+ const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
249
+
250
+ // Render a single-line progress: spinner + label + [###-------] NN%
251
+ let lastRender = '';
252
+ function computePercentFromData(d){
253
+ if (!d) return 0;
254
+ // Prefer explicit progress when it's > 0; otherwise fall back to stage/status mapping
255
+ if (typeof d.progress === 'number' && !Number.isNaN(d.progress) && d.progress > 0) return Math.max(0, Math.min(100, Math.round(d.progress)));
256
+
257
+ // stage-aware mapping: try `stage` first (more granular), then `status`
258
+ const normalize = s => String(s || '').toLowerCase().replace(/[_-]+/g,' ').trim();
259
+ const stage = d && d.stage ? normalize(d.stage) : '';
260
+ const status = d && d.status ? normalize(d.status) : '';
261
+
262
+ // Exact stage mapping tuned to pipeline:
263
+ // - index fetch/extraction: 5%
264
+ // - assets fetch: 10%
265
+ // - render/DOM processing: 50%
266
+ // - screenshots/capture: 70%
267
+ // - analysis/audit/reporting: 90%
268
+ // - recording/video: 99%
269
+ // - finished: 100%
270
+
271
+ const mapStage = s => {
272
+ if (!s) return null;
273
+ if (s.includes('index') || s.includes('extract') || s.includes('fetch index') || s.includes('fetch-main')) return 5;
274
+ if (s.includes('asset') || s.includes('fetch') || s.includes('download')) return 10;
275
+ if (s.includes('render') || s.includes('dom') || s.includes('parse') || s.includes('layout')) return 50;
276
+ if (s.includes('screenshot') || s.includes('capture') || s.includes('screen')) return 70;
277
+ if (s.includes('analyse') || s.includes('analy') || s.includes('audit') || s.includes('report')) return 90;
278
+ if (s.includes('record') || s.includes('video') || s.includes('recording')) return 99;
279
+ if (s.includes('final') || s.includes('finish') || s.includes('complete') || s.includes('success') || s === 'done') return 100;
280
+ return null;
281
+ };
282
+
283
+ // prefer stage mapping for granularity
284
+ const stagePct = mapStage(stage);
285
+ if (stagePct !== null) return stagePct;
286
+
287
+ const statusPct = mapStage(status);
288
+ if (statusPct !== null) return statusPct;
289
+
290
+ // Prefer verbatim pipeline messages from server/modules/scan-pipeline.js
291
+ // Map known server messages to percentages (more accurate):
292
+ const msg = (stage + ' ' + status).trim();
293
+ if (/fetching initial html/i.test(msg)) return 5;
294
+ if (/parsing document structure/i.test(msg)) return 15;
295
+ if (/analyzing javascript/i.test(msg)) return 20;
296
+ if (/building dependency graph/i.test(msg)) return 30;
297
+ if (/capturing network requests/i.test(msg)) return 35;
298
+ if (/analyzing resource loading/i.test(msg)) return 45;
299
+ if (/pagespeed/i.test(msg)) return 50;
300
+ if (/mozilla observatory/i.test(msg)) return 65;
301
+ if (/detecting interactive/i.test(msg)) return 70;
302
+ if (/mapping user interactions/i.test(msg)) return 80;
303
+ if (/comparing no-?js/i.test(msg)) return 85;
304
+ if (/calculating js dependency/i.test(msg)) return 92;
305
+ if (/recording session video/i.test(msg)) return 99;
306
+ if (/analysis complete/i.test(msg) || /finaliz/i.test(msg)) return 100;
307
+
308
+ // sensible fallbacks
309
+ if (status.includes('queued') || status.includes('pending') || status.includes('accepted')) return 1;
310
+ if (status.includes('start') || status.includes('running') || status.includes('in progress') || status.includes('inprogress')) return 20;
311
+ return 0;
312
+ }
313
+
314
+ function renderLine(d){
315
+ const spinner = spinnerFrames[spinnerIndex % spinnerFrames.length];
316
+ spinnerIndex += 1;
317
+
318
+ // robustly extract status string
319
+ let rawLabel = 'waiting';
320
+ if (d && d.status) {
321
+ if (typeof d.status === 'string') rawLabel = d.status;
322
+ else if (typeof d.status === 'object' && (d.status.state || d.status.name)) rawLabel = d.status.state || d.status.name;
323
+ else rawLabel = String(d.status);
324
+ }
325
+ const label = String(rawLabel).replace(/[_-]+/g, ' ').trim().replace(/\b\w/g, c => c.toUpperCase());
326
+
327
+ // Update targetPercent from latest data but don't immediately drop lastDisplayedPercent
328
+ const serverPct = computePercentFromData(d);
329
+ if (serverPct >= 100) {
330
+ targetPercent = 100;
331
+ } else if (serverPct > lastDisplayedPercent) {
332
+ targetPercent = Math.max(targetPercent, serverPct);
333
+ } else if (serverPct > 0 && lastDisplayedPercent === 0) {
334
+ targetPercent = Math.max(targetPercent, serverPct);
335
+ }
336
+
337
+ // If explicit numeric progress provided and > lastDisplayed, accept it as target
338
+ if (d && typeof d.progress === 'number' && d.progress > lastDisplayedPercent) {
339
+ targetPercent = Math.max(targetPercent, Math.max(0, Math.min(100, Math.round(d.progress))));
340
+ }
341
+
342
+ // Smooth display: approach targetPercent gradually each tick
343
+ if (lastDisplayedPercent < targetPercent) {
344
+ const diff = targetPercent - lastDisplayedPercent;
345
+ const step = Math.max(1, Math.ceil(diff / 4));
346
+ lastDisplayedPercent = Math.min(targetPercent, lastDisplayedPercent + step);
347
+ }
348
+
349
+ // If target is behind (rare), don't regress unless finished
350
+ if (targetPercent === 100) lastDisplayedPercent = 100;
155
351
 
352
+ const pct = lastDisplayedPercent;
353
+ const barWidth = 20;
354
+ const filled = Math.round((pct/100) * barWidth);
355
+ const fillStr = filled ? chalk.green('#'.repeat(filled)) : '';
356
+ const emptyStr = chalk.dim('-'.repeat(Math.max(0, barWidth - filled)));
357
+ const bar = '[' + fillStr + emptyStr + ']';
358
+ const qp = (d && typeof d.queuePosition === 'number' && d.queuePosition > 0) ? ` Q#${d.queuePosition}` : '';
359
+ const line = `${chalk.cyan(spinner)} ${chalk.bold(label)} ${bar} ${chalk.yellow(String(pct).padStart(3) + '%')}${qp}`;
360
+ if (line !== lastRender) {
361
+ try {
362
+ readline.clearLine(process.stdout, 0);
363
+ readline.cursorTo(process.stdout, 0);
364
+ process.stdout.write(line);
365
+ } catch (e) {
366
+ console.log(line);
367
+ }
368
+ lastRender = line;
369
+ }
370
+ lastPollTs = Date.now();
371
+ }
372
+
373
+ console.log(chalk.blue('Queued scan. Polling for progress...'));
374
+ let headerState = 'queued';
375
+ function startSpinner(){
376
+ if (spinnerTimer) return;
377
+ if (!process.stdout || !process.stdout.isTTY) return;
378
+ spinnerTimer = setInterval(() => {
379
+ try { renderLine(data); } catch (e) { /* ignore */ }
380
+ }, 80);
381
+ }
382
+ function stopSpinner(){ if (spinnerTimer) { clearInterval(spinnerTimer); spinnerTimer = null; } }
156
383
  let recheckedAfterTerminal = false;
384
+ let data = null;
385
+ startSpinner();
157
386
  while (true) {
158
387
  const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
159
388
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
@@ -163,41 +392,56 @@ async function run(argv) {
163
392
  try { const j = await r.json(); if (j && j.retry_after) retrySeconds = j.retry_after; } catch (e) {}
164
393
  try { const ra = r.headers.get && r.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
165
394
  if (retrySeconds) console.error(chalk.yellow(`Rate limit reached. Try again in ${retrySeconds} seconds.`)); else console.error(chalk.yellow('Rate limit reached. Try again later.'));
395
+ stopSpinner();
166
396
  process.exit(1);
167
397
  }
168
398
 
169
- let data = null;
170
399
  try { data = await r.json(); } catch (e) { data = null; }
171
400
 
172
- if (data) {
173
- if (data.queuePosition !== undefined) console.log(chalk.cyan('Queue position:'), data.queuePosition);
174
- if (data.progress !== undefined) console.log(chalk.cyan('Progress:'), String(data.progress) + '%');
175
- const rawStatus = data.status !== undefined && data.status !== null ? String(data.status) : '';
176
- const displayStatus = rawStatus.trim();
177
- if (displayStatus) console.log(chalk.cyan('Status:'), displayStatus);
178
- if (data.stage) console.log(chalk.dim('Stage:'), data.stage);
179
- } else {
180
- console.log(chalk.dim('Waiting for server response...'));
401
+ // If we moved out of queue, replace the queued header with "Started scan."
402
+ const nonQueueStates = ['started','running','in progress','inprogress','processing','scanning'];
403
+ const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
404
+ if (headerState === 'queued' && statusStr && !['queued','pending','accepted'].includes(statusStr)) {
405
+ headerState = 'started';
406
+ try {
407
+ if (process.stdout && process.stdout.isTTY) {
408
+ readline.moveCursor(process.stdout, 0, -1);
409
+ readline.clearLine(process.stdout, 0);
410
+ console.log(chalk.blue('Started scan.'));
411
+ } else {
412
+ console.log(chalk.blue('Started scan.'));
413
+ }
414
+ } catch (e) {
415
+ console.log(chalk.blue('Started scan.'));
416
+ }
181
417
  }
182
418
 
183
- const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
184
- const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
185
- const done = statusStr && terminalStates.includes(statusStr);
419
+ // render progress line
420
+ renderLine(data);
421
+
422
+ // consider terminal if status matches known terminal states OR percent maps to 100
423
+ const pctNow = computePercentFromData(data);
424
+ const done = (statusStr && terminalStates.includes(statusStr)) || (typeof pctNow === 'number' && pctNow === 100);
186
425
 
187
426
  if (done) {
188
- // If server signals completion but hasn't populated result fields yet,
189
- // re-check once after a short delay to allow finalization.
190
427
  const payloadKeys = data ? Object.keys(data).filter(k => !['status','queuePosition','progress','stage'].includes(k)) : [];
191
428
  const hasPayload = payloadKeys.length > 0;
192
429
  if (!hasPayload && !recheckedAfterTerminal) {
193
430
  recheckedAfterTerminal = true;
194
- console.log(chalk.yellow('Status is terminal but results not yet available — rechecking shortly...'));
195
- await new Promise(r => setTimeout(r, 2000));
196
- continue; // loop will fetch again
431
+ // give the server a moment to populate results
432
+ await sleep(2000);
433
+ // continue to refresh once more
434
+ try { const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } }); data = await r2.json(); } catch (e) {}
435
+ // final render
436
+ renderLine(data);
197
437
  }
198
438
 
439
+ // finish
440
+ stopSpinner();
441
+ try { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); } catch (e) { /* ignore */ }
199
442
  console.log(chalk.green('Scan finished. Full results:'));
200
- console.log(JSON.stringify(data, null, 2));
443
+ if (opts && opts.json) console.log(JSON.stringify(data, null, 2));
444
+ else printFullReport(data);
201
445
  return;
202
446
  }
203
447
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "3.0.2",
3
+ "version": "4.0.0",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"