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.
- package/dist/package/README.md +3 -3
- package/dist/package/index.js +2 -2
- package/index.js +266 -22
- package/package.json +1 -1
package/dist/package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Commands
|
|
|
40
40
|
{
|
|
41
41
|
"url": "https://example.com",
|
|
42
42
|
"source": "cli",
|
|
43
|
-
"client": { "name": "subto-cli", "version": "
|
|
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-
|
|
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-
|
|
107
|
+
npm install -g ./dist/subto-4.0.0.tgz
|
|
108
108
|
```
|
package/dist/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: '
|
|
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 || '
|
|
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: '
|
|
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 || '
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
await
|
|
196
|
-
continue
|
|
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
|
|