subto 2.0.2 → 3.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/README.md +7 -200
- package/bin/subto.js +18 -0
- package/dist/package/README.md +108 -0
- package/dist/package/docs/cli-section.md +27 -0
- package/{cli → dist/package}/index.js +22 -190
- package/dist/package/scripts/fix-node-domexception.js +36 -0
- package/dist/package/scripts/prepublish-check.js +16 -0
- package/index.js +219 -0
- package/package.json +30 -67
- package/scripts/fix-node-domexception.js +36 -0
- package/scripts/prepublish-check.js +88 -0
- package/cli/README.md +0 -66
- package/server/index.js +0 -2400
- /package/{cli → dist/package}/bin/subto.js +0 -0
package/README.md
CHANGED
|
@@ -1,209 +1,16 @@
|
|
|
1
|
-
# Subto
|
|
1
|
+
# Subto CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Install:
|
|
4
4
|
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- **Deep Runtime Analysis**: Full Playwright-based crawling with JavaScript execution
|
|
8
|
-
- **Network Interception**: Captures all requests, responses, and timing data
|
|
9
|
-
- **Interaction Simulation**: Programmatically tests buttons, inputs, and interactive elements
|
|
10
|
-
- **Lighthouse Integration**: Performance, accessibility, SEO, and best practices scoring
|
|
11
|
-
- **SEO & Markup Validation**: W3C Validator, Google Mobile-Friendly Test integration
|
|
12
|
-
- **Performance Testing**: Google PageSpeed, WebPageTest, GTmetrix integration
|
|
13
|
-
- **Security Audit**: Mozilla Observatory, Security Headers, SSL Labs, Safe Browsing integration
|
|
14
|
-
- **Malware Detection**: VirusTotal, Hybrid Analysis, URLScan.io support
|
|
15
|
-
- **AI API Selection**: Ask AI to choose the best scanning API for your needs
|
|
16
|
-
- **No-JS Differential**: Compares JS-enabled vs disabled behavior
|
|
17
|
-
- **AI Code Surgeon**: Upload your code and get AI-powered fixes
|
|
18
|
-
|
|
19
|
-
## Quick Start
|
|
20
|
-
|
|
21
|
-
### Prerequisites
|
|
22
|
-
|
|
23
|
-
- Node.js 18+
|
|
24
|
-
- npm or yarn
|
|
25
|
-
|
|
26
|
-
### Installation
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
# Install dependencies
|
|
30
|
-
npm install
|
|
31
|
-
|
|
32
|
-
# Install Playwright browsers
|
|
33
|
-
npx playwright install chromium
|
|
34
|
-
|
|
35
|
-
# Copy environment variables
|
|
36
|
-
cp .env.example .env
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
### Configuration
|
|
40
|
-
|
|
41
|
-
Edit `.env` and add your OpenRouter API key for AI features:
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
OPENROUTER_API_KEY=your_key_here
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Get a free API key at [OpenRouter](https://openrouter.ai)
|
|
48
|
-
|
|
49
|
-
### Optional API Keys
|
|
50
|
-
|
|
51
|
-
Add these to your `.env` for enhanced scanning capabilities (all have generous free tiers):
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
# Performance & Speed (Scalable Free Options)
|
|
55
|
-
GOOGLE_PAGESPEED_API_KEY=your_key # Google PageSpeed Insights
|
|
56
|
-
WEBPAGETEST_API_KEY=your_key # WebPageTest (public instance available)
|
|
57
|
-
GOOGLE_MOBILE_FRIENDLY_API_KEY=your_key # Google Mobile-Friendly Test
|
|
58
|
-
|
|
59
|
-
# SEO & Markup Validation
|
|
60
|
-
# W3C Markup Validator (no key needed) # HTML validation for SEO
|
|
61
|
-
|
|
62
|
-
# Security & Malware (No/Low Limits)
|
|
63
|
-
VIRUSTOTAL_API_KEY=your_key # VirusTotal (500 requests/day free)
|
|
64
|
-
GOOGLE_SAFE_BROWSING_API_KEY=your_key # Google Safe Browsing
|
|
65
|
-
URLSCAN_API_KEY=your_key # URLScan.io (free tier)
|
|
66
|
-
HYBRID_ANALYSIS_API_KEY=your_key # Hybrid Analysis (free tier)
|
|
67
|
-
|
|
68
|
-
# Always Free (No API Keys Needed)
|
|
69
|
-
# Mozilla Observatory, Security Headers, SSL Labs
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
All APIs have free tiers. See `.env.example` for details.
|
|
73
|
-
|
|
74
|
-
### Running
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
# Development
|
|
78
|
-
npm run dev
|
|
79
|
-
|
|
80
|
-
# Production
|
|
81
|
-
npm start
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Visit `http://localhost:3000`
|
|
85
|
-
|
|
86
|
-
## Architecture
|
|
87
|
-
|
|
88
|
-
```
|
|
89
|
-
quantumreasoning/
|
|
90
|
-
├── public/ # Frontend assets
|
|
91
|
-
│ ├── index.html # Single-page app
|
|
92
|
-
│ ├── styles.css # Exact styling spec
|
|
93
|
-
│ └── app.js # Frontend logic
|
|
94
|
-
├── server/
|
|
95
|
-
│ ├── index.js # Express server + WebSocket
|
|
96
|
-
│ └── modules/
|
|
97
|
-
│ ├── scan-pipeline.js # 7-phase analysis engine
|
|
98
|
-
│ ├── ai-analyzer.js # OpenRouter AI integration
|
|
99
|
-
│ ├── file-manager.js # Upload/ZIP handling
|
|
100
|
-
│ └── data-store.js # In-memory storage
|
|
101
|
-
├── package.json
|
|
102
|
-
└── .env.example
|
|
103
5
|
```
|
|
104
|
-
|
|
105
|
-
## Scan Pipeline
|
|
106
|
-
|
|
107
|
-
1. **Fetch Initial HTML** - Loads page with Playwright
|
|
108
|
-
2. **Execute JavaScript Runtime** - Captures all JS files and builds AST
|
|
109
|
-
3. **Intercept Network Requests** - Records all network activity
|
|
110
|
-
4. **Simulate Interactions** - Hovers, clicks, types on interactive elements
|
|
111
|
-
5. **Run Lighthouse** - Google PageSpeed Insights API
|
|
112
|
-
6. **Audit Security** - Mozilla Observatory + OWASP checks
|
|
113
|
-
7. **No-JS Differential** - Compares behavior without JavaScript
|
|
114
|
-
|
|
115
|
-
## API Endpoints
|
|
116
|
-
|
|
117
|
-
### Scan
|
|
118
|
-
|
|
119
|
-
```
|
|
120
|
-
POST /api/v1/scan
|
|
121
|
-
Body: { "url": "https://subto.one" }
|
|
122
|
-
Response: { "scanId": "uuid", "status": "started" }
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Notes on queuing and rate limiting:
|
|
126
|
-
|
|
127
|
-
- Concurrency limit: the server allows up to `MAX_CONCURRENT_SCANS` (default 50) scans to run concurrently.
|
|
128
|
-
- If the server is at capacity, API clients can opt into an automatic queue by sending the header `X-Accept-Queue: true` with the POST request. In that case the request will be accepted and queued; the response will be `202 Accepted` with JSON `{ scanId, status: 'queued', queuePosition }`.
|
|
129
|
-
- If the client does not opt into queuing and the server is at capacity, the API will return `429 Too Many Requests` with a short message instructing the client to retry or set `X-Accept-Queue: true`.
|
|
130
|
-
- The UI automatically sets `X-Accept-Queue: true` and displays `Queuing` plus the user's position (e.g., `You are #3 in queue`).
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
### Get Results
|
|
134
|
-
|
|
135
|
-
```
|
|
136
|
-
GET /api/v1/scan/:scanId
|
|
137
|
-
Response: Full scan data
|
|
6
|
+
npm install -g subto
|
|
138
7
|
```
|
|
139
8
|
|
|
140
|
-
|
|
9
|
+
Usage:
|
|
141
10
|
|
|
142
11
|
```
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Response: { "summary": "...", "changes": [...] }
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## File Upload
|
|
149
|
-
|
|
150
|
-
### Supported Types
|
|
151
|
-
- JavaScript: `.js`, `.ts`, `.jsx`, `.tsx`
|
|
152
|
-
- Styles: `.css`, `.scss`
|
|
153
|
-
- Markup: `.html`, `.vue`, `.svelte`
|
|
154
|
-
- Data: `.json`, `.env`, `.md`
|
|
155
|
-
|
|
156
|
-
### Limits
|
|
157
|
-
- Single file: 50 MB max
|
|
158
|
-
- Total upload: 250 MB max
|
|
159
|
-
- File count: 5,000 files max
|
|
160
|
-
|
|
161
|
-
### Excluded Folders
|
|
162
|
-
- `node_modules/`
|
|
163
|
-
- `.git/`
|
|
164
|
-
- `.next/`, `dist/`, `build/`
|
|
165
|
-
|
|
166
|
-
## AI Models (Free Tier)
|
|
167
|
-
|
|
168
|
-
- **Default**: `deepseek/deepseek-r1:free`
|
|
169
|
-
- **Code Analysis**: `qwen/qwen3-coder:free`
|
|
170
|
-
- **Security**: `mistralai/devstral-small:free`
|
|
171
|
-
|
|
172
|
-
### Rate Limits
|
|
173
|
-
- 5 seconds between requests
|
|
174
|
-
- 50 requests per day
|
|
175
|
-
|
|
176
|
-
## Data Retention
|
|
177
|
-
|
|
178
|
-
All scan data is automatically deleted after 24 hours. No user accounts required.
|
|
179
|
-
|
|
180
|
-
## Security
|
|
181
|
-
|
|
182
|
-
- No binary file uploads
|
|
183
|
-
- Server-side validation even if client-side passes
|
|
184
|
-
- Streaming uploads to disk, not memory
|
|
185
|
-
- Directory traversal prevention
|
|
186
|
-
- MIME type validation
|
|
187
|
-
|
|
188
|
-
## License
|
|
189
|
-
|
|
190
|
-
MIT
|
|
191
|
-
|
|
192
|
-
## Deployment (Docker + nginx reverse-proxy)
|
|
193
|
-
|
|
194
|
-
This repo includes a simple production-ready scaffold under `deploy/` that runs the Node app behind an nginx reverse-proxy which terminates TLS and forwards requests (including WebSocket upgrades) to the app.
|
|
195
|
-
|
|
196
|
-
Quick local test (self-signed cert):
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
cd deploy
|
|
200
|
-
./mk-self-signed.sh # creates deploy/certs/fullchain.pem and privkey.pem
|
|
201
|
-
docker compose up --build
|
|
12
|
+
subto login # store API key
|
|
13
|
+
subto scan <url> # request a scan
|
|
202
14
|
```
|
|
203
15
|
|
|
204
|
-
|
|
205
|
-
- Use Node.js 22+ in your runtime (required by Lighthouse v13).
|
|
206
|
-
- Terminate TLS at your load balancer (Cloud Load Balancer, nginx, etc.) and forward plain HTTP to the Node container.
|
|
207
|
-
- Ensure WebSocket upgrades are forwarded by the proxy.
|
|
208
|
-
- Provide secrets via environment variables or your hosting secret manager (do NOT commit secrets).
|
|
209
|
-
|
|
16
|
+
This package is a production CLI; it intentionally omits development instructions.
|
package/bin/subto.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* CLI executable that runs the package's `run` function */
|
|
3
|
+
try {
|
|
4
|
+
const mod = require('../index.js');
|
|
5
|
+
if (mod && typeof mod.run === 'function') {
|
|
6
|
+
// pass process.argv (node, script, ...user args)
|
|
7
|
+
mod.run(process.argv).catch(err => {
|
|
8
|
+
console.error('Error:', err && err.message ? err.message : String(err));
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
|
11
|
+
} else {
|
|
12
|
+
console.error('subto: CLI entrypoint not found.');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error('subto: failed to start:', err && err.message ? err.message : String(err));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Subto CLI
|
|
2
|
+
|
|
3
|
+
This folder contains the Subto command-line client which is a thin wrapper around the Subto.One API.
|
|
4
|
+
|
|
5
|
+
Installation
|
|
6
|
+
|
|
7
|
+
- Global (recommended via npm):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g subto-cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- Or run locally from the `cli` folder during development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd cli
|
|
17
|
+
npm install
|
|
18
|
+
node bin/subto.js --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Local testing (before publishing)
|
|
22
|
+
|
|
23
|
+
- Link the package locally and test the installed command:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd cli
|
|
27
|
+
npm link
|
|
28
|
+
subto --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Commands
|
|
32
|
+
|
|
33
|
+
- `subto login`
|
|
34
|
+
- Prompts for your API key and stores it in `~/.subto/config.json`.
|
|
35
|
+
|
|
36
|
+
- `subto scan <url>`
|
|
37
|
+
- Requests a scan for `<url>` from the Subto API. Example body:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"url": "https://example.com",
|
|
42
|
+
"source": "cli",
|
|
43
|
+
"client": { "name": "subto-cli", "version": "3.0.0" }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
|
|
49
|
+
- The CLI calls the remote API and respects server-side rate limiting. If a rate limit is encountered the CLI prints a friendly message like:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Rate limit reached. Try again in X seconds.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- The API key is stored at `~/.subto/config.json` with restrictive permissions (0600).
|
|
56
|
+
- The CLI never logs or prints API keys or other secrets.
|
|
57
|
+
- The CLI never performs website scanning locally — it only calls the remote API.
|
|
58
|
+
|
|
59
|
+
- `--wait` flag: block and poll until the scan completes
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
subto scan https://example.com --wait
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
This will poll the server every 5 seconds (respecting `Retry-After`) and display queue position, progress percentage and stages as provided by the API, then print the full JSON result when finished.
|
|
68
|
+
|
|
69
|
+
Default behavior
|
|
70
|
+
|
|
71
|
+
- By default the CLI will automatically poll when the server immediately returns a queued/started response. If you prefer to return immediately, use `--no-wait`.
|
|
72
|
+
|
|
73
|
+
Example (do not wait):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
subto scan https://example.com --no-wait
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Publishing the CLI
|
|
80
|
+
|
|
81
|
+
Exact steps to publish (do not run automatically):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm login
|
|
85
|
+
npm publish
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Pre-publish check
|
|
89
|
+
|
|
90
|
+
The package runs a `prepublishOnly` script which verifies `bin/subto.js` exists and that the `bin` mapping is correct. The script also attempts to set the executable bit on the entry file.
|
|
91
|
+
|
|
92
|
+
API docs section (link)
|
|
93
|
+
|
|
94
|
+
See the bundled API docs excerpt for the Subto CLI in `./docs/cli-section.md`.
|
|
95
|
+
|
|
96
|
+
Download
|
|
97
|
+
|
|
98
|
+
After publishing or packing, a distributable tarball will be available under `./dist/` e.g.:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
./dist/subto-3.0.0.tgz
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You can download that file directly and install locally with:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install -g ./dist/subto-3.0.0.tgz
|
|
108
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Subto CLI
|
|
2
|
+
|
|
3
|
+
Installation
|
|
4
|
+
|
|
5
|
+
- Install via npm:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install -g subto-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Commands
|
|
12
|
+
|
|
13
|
+
- `subto login`
|
|
14
|
+
- Prompts for your API key and stores it in `~/.subto/config.json`.
|
|
15
|
+
|
|
16
|
+
- `subto scan <url>`
|
|
17
|
+
- Requests a scan for `<url>` from the Subto API.
|
|
18
|
+
|
|
19
|
+
Notes
|
|
20
|
+
|
|
21
|
+
- The CLI calls the remote API and respects server-side rate limiting. If a rate limit is encountered the CLI prints a friendly message like:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Rate limit reached. Try again in X seconds.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- For more information and installation help see `/cli/README.md` in this repository.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/* Subto CLI implementation */
|
|
3
2
|
const { Command } = require('commander');
|
|
4
3
|
const fs = require('fs').promises;
|
|
@@ -12,83 +11,7 @@ const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
|
|
|
12
11
|
const CONFIG_DIR = path.join(os.homedir(), '.subto');
|
|
13
12
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
14
13
|
const DEFAULT_API_BASE = 'https://subto.one';
|
|
15
|
-
const CLIENT_META = { name: 'subto-cli', version: '0.
|
|
16
|
-
|
|
17
|
-
// Simple terminal UI helper (no extra deps) for polished spinner/progress
|
|
18
|
-
class TerminalUI {
|
|
19
|
-
constructor() {
|
|
20
|
-
this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
21
|
-
this.i = 0;
|
|
22
|
-
this.timer = null;
|
|
23
|
-
this.visible = false;
|
|
24
|
-
this.lastRenderLines = 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
start(message) {
|
|
28
|
-
if (!process.stdout.isTTY) { console.log(message); return; }
|
|
29
|
-
this.visible = true;
|
|
30
|
-
this.message = message || '';
|
|
31
|
-
this._render();
|
|
32
|
-
this.timer = setInterval(() => { this.i = (this.i + 1) % this.frames.length; this._render(); }, 80);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
update(state) {
|
|
36
|
-
this.state = Object.assign({}, this.state, state);
|
|
37
|
-
if (this.visible) this._render();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
stop() {
|
|
41
|
-
if (this.timer) clearInterval(this.timer);
|
|
42
|
-
this.timer = null;
|
|
43
|
-
if (this.visible && process.stdout.isTTY) {
|
|
44
|
-
// clear previous render
|
|
45
|
-
for (let j = 0; j < this.lastRenderLines; j++) {
|
|
46
|
-
readline.clearLine(process.stdout, 0);
|
|
47
|
-
readline.cursorTo(process.stdout, 0);
|
|
48
|
-
if (j < this.lastRenderLines - 1) process.stdout.write('\u001b[1A');
|
|
49
|
-
}
|
|
50
|
-
this.visible = false;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
_render() {
|
|
55
|
-
const out = [];
|
|
56
|
-
const spin = this.frames[this.i];
|
|
57
|
-
const title = this.message || '';
|
|
58
|
-
const status = this.state && this.state.status ? String(this.state.status) : '';
|
|
59
|
-
const stage = this.state && this.state.stage ? String(this.state.stage) : '';
|
|
60
|
-
const qpos = this.state && (this.state.queuePosition !== undefined) ? `Queue: ${this.state.queuePosition}` : '';
|
|
61
|
-
const progress = (this.state && (this.state.progress !== undefined)) ? Number(this.state.progress) : null;
|
|
62
|
-
|
|
63
|
-
// Build a compact progress bar
|
|
64
|
-
let bar = '';
|
|
65
|
-
if (progress !== null && !Number.isNaN(progress)) {
|
|
66
|
-
const pct = Math.max(0, Math.min(100, Math.round(progress)));
|
|
67
|
-
const width = 30;
|
|
68
|
-
const filled = Math.round((pct / 100) * width);
|
|
69
|
-
bar = '[' + chalk.cyan('='.repeat(filled)) + chalk.dim('-'.repeat(width - filled)) + `] ${pct}%`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
out.push(`${chalk.bold.cyan(spin)} ${chalk.bold(title)}`);
|
|
73
|
-
if (status) out.push(`${chalk.green('Status:')} ${status}`);
|
|
74
|
-
if (stage) out.push(`${chalk.dim('Stage:')} ${stage}`);
|
|
75
|
-
if (qpos) out.push(chalk.magenta(qpos));
|
|
76
|
-
if (bar) out.push(bar);
|
|
77
|
-
|
|
78
|
-
// Erase previous
|
|
79
|
-
if (process.stdout.isTTY) {
|
|
80
|
-
for (let j = 0; j < this.lastRenderLines; j++) {
|
|
81
|
-
readline.clearLine(process.stdout, 0);
|
|
82
|
-
readline.cursorTo(process.stdout, 0);
|
|
83
|
-
process.stdout.write('\u001b[1A');
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const payload = out.join('\n') + '\n';
|
|
88
|
-
process.stdout.write(payload);
|
|
89
|
-
this.lastRenderLines = payload.split('\n').length;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
14
|
+
const CLIENT_META = { name: 'subto-cli', version: '3.0.0' };
|
|
92
15
|
|
|
93
16
|
function configFilePath() { return CONFIG_PATH; }
|
|
94
17
|
|
|
@@ -148,7 +71,7 @@ async function postScan(url, apiKey) {
|
|
|
148
71
|
const body = { url, source: 'cli', client: CLIENT_META };
|
|
149
72
|
const fetchFn = global.fetch;
|
|
150
73
|
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
151
|
-
const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', '
|
|
74
|
+
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) });
|
|
152
75
|
const text = await res.text();
|
|
153
76
|
let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
154
77
|
return { status: res.status, headers: res.headers, body: data };
|
|
@@ -156,26 +79,19 @@ async function postScan(url, apiKey) {
|
|
|
156
79
|
|
|
157
80
|
function printScanSummary(obj) {
|
|
158
81
|
if (!obj || typeof obj !== 'object') { console.log(obj); return; }
|
|
159
|
-
|
|
160
|
-
if (obj.id)
|
|
161
|
-
if (obj.url)
|
|
162
|
-
if (obj.status)
|
|
163
|
-
if (obj.createdAt)
|
|
164
|
-
if (obj.summary)
|
|
165
|
-
const
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
const labelWidth = Math.max(...rows.map(r => r[0].length), 0);
|
|
169
|
-
console.log(chalk.bgBlue.black(' SCAN ') + ' ' + chalk.bold('Summary'));
|
|
170
|
-
for (const [label, value] of rows) {
|
|
171
|
-
const padded = label.padEnd(labelWidth);
|
|
172
|
-
console.log(chalk.green(` ${padded}:`) + ' ' + chalk.white(String(value)));
|
|
173
|
-
}
|
|
82
|
+
console.log(chalk.bold('Scan result:'));
|
|
83
|
+
if (obj.id) console.log(chalk.green(' ID:') + ' ' + obj.id);
|
|
84
|
+
if (obj.url) console.log(chalk.green(' URL:') + ' ' + obj.url);
|
|
85
|
+
if (obj.status) console.log(chalk.green(' Status:') + ' ' + obj.status);
|
|
86
|
+
if (obj.createdAt) console.log(chalk.green(' Created:') + ' ' + obj.createdAt);
|
|
87
|
+
if (obj.summary) console.log(chalk.green(' Summary:') + ' ' + obj.summary);
|
|
88
|
+
const keys = Object.keys(obj).filter(k => !['id','url','status','createdAt','summary'].includes(k));
|
|
89
|
+
if (keys.length) console.log(chalk.dim(' Additional keys: ' + keys.join(', ')));
|
|
174
90
|
}
|
|
175
91
|
|
|
176
92
|
async function run(argv) {
|
|
177
93
|
const program = new Command();
|
|
178
|
-
program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '0.
|
|
94
|
+
program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '3.0.0');
|
|
179
95
|
|
|
180
96
|
program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
|
|
181
97
|
try {
|
|
@@ -191,7 +107,6 @@ async function run(argv) {
|
|
|
191
107
|
.command('scan <url>')
|
|
192
108
|
.description('Request a scan for <url> via the Subto API')
|
|
193
109
|
.option('--json', 'Output raw JSON')
|
|
194
|
-
.option('--full', 'When available, fetch offloaded large results (GCS) and print them')
|
|
195
110
|
.option('--wait', 'Poll for completion and show progress')
|
|
196
111
|
.option('--no-wait', 'Do not poll; return immediately')
|
|
197
112
|
.action(async (url, opts) => {
|
|
@@ -222,16 +137,6 @@ async function run(argv) {
|
|
|
222
137
|
return;
|
|
223
138
|
}
|
|
224
139
|
|
|
225
|
-
// If server already returned full structured results (not HTML), show them
|
|
226
|
-
if (!shouldPoll && resp.body && typeof resp.body === 'object') {
|
|
227
|
-
// Heuristic: if response contains detailed scan fields, print full JSON
|
|
228
|
-
const hasDetailed = resp.body.results || resp.body.audits || resp.body.performance || resp.body.overview || resp.body.summary;
|
|
229
|
-
if (hasDetailed) {
|
|
230
|
-
console.log(JSON.stringify(resp.body, null, 2));
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
140
|
// If we should poll, poll the scan resource until completion and print progress.
|
|
236
141
|
if (shouldPoll) {
|
|
237
142
|
const scanId = (resp.body && (resp.body.scanId || resp.body.id || resp.body.scan_id));
|
|
@@ -246,8 +151,7 @@ async function run(argv) {
|
|
|
246
151
|
|
|
247
152
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
248
153
|
let interval = 5000;
|
|
249
|
-
|
|
250
|
-
ui.start('Queued scan. Polling for progress...');
|
|
154
|
+
console.log(chalk.blue('Queued scan. Polling for progress...'));
|
|
251
155
|
|
|
252
156
|
let recheckedAfterTerminal = false;
|
|
253
157
|
while (true) {
|
|
@@ -266,14 +170,14 @@ async function run(argv) {
|
|
|
266
170
|
try { data = await r.json(); } catch (e) { data = null; }
|
|
267
171
|
|
|
268
172
|
if (data) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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);
|
|
275
179
|
} else {
|
|
276
|
-
|
|
180
|
+
console.log(chalk.dim('Waiting for server response...'));
|
|
277
181
|
}
|
|
278
182
|
|
|
279
183
|
const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
|
|
@@ -287,85 +191,13 @@ async function run(argv) {
|
|
|
287
191
|
const hasPayload = payloadKeys.length > 0;
|
|
288
192
|
if (!hasPayload && !recheckedAfterTerminal) {
|
|
289
193
|
recheckedAfterTerminal = true;
|
|
290
|
-
|
|
194
|
+
console.log(chalk.yellow('Status is terminal but results not yet available — rechecking shortly...'));
|
|
291
195
|
await new Promise(r => setTimeout(r, 2000));
|
|
292
196
|
continue; // loop will fetch again
|
|
293
197
|
}
|
|
294
|
-
// render final state then stop UI
|
|
295
|
-
ui.update({ status: 'completed', progress: 100 });
|
|
296
|
-
ui.stop();
|
|
297
|
-
|
|
298
|
-
console.log('\n' + chalk.bgGreen.black(' DONE ') + ' ' + chalk.bold('Scan finished. Full results:') + '\n');
|
|
299
|
-
|
|
300
|
-
// If user asked for full and results were offloaded to GCS, try fetching that
|
|
301
|
-
if (opts.full && data && (data.resultsGcsPath || (data.results && data.results.resultsGcsPath))) {
|
|
302
|
-
try {
|
|
303
|
-
const gcsPath = data.resultsGcsPath || (data.results && data.results.resultsGcsPath);
|
|
304
|
-
const publicUrl = (gcsPath || '').replace(/^gs:\/\//, 'https://storage.googleapis.com/');
|
|
305
|
-
console.log(chalk.cyan('Fetching offloaded results from:'), publicUrl);
|
|
306
|
-
const r2 = await fetchFn(publicUrl, { headers: { 'User-Agent': `${CLIENT_META.name}/${CLIENT_META.version}` } });
|
|
307
|
-
if (r2.ok) {
|
|
308
|
-
const txt = await r2.text();
|
|
309
|
-
try { const parsed = JSON.parse(txt); console.log(JSON.stringify(parsed, null, 2)); } catch (e) { console.log(txt); }
|
|
310
|
-
return;
|
|
311
|
-
} else {
|
|
312
|
-
console.log(chalk.yellow('Could not fetch offloaded results; status:'), r2.status);
|
|
313
|
-
}
|
|
314
|
-
} catch (e) {
|
|
315
|
-
console.log(chalk.yellow('Failed to fetch offloaded results:'), e && e.message ? e.message : e);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Print video URL if present (prefer a canonical URL)
|
|
320
|
-
try {
|
|
321
|
-
const vid = (data && data.results && data.results.videoUrl) || data.videoUrl || (data && data.video);
|
|
322
|
-
if (vid && typeof vid === 'string' && vid.startsWith('http')) {
|
|
323
|
-
console.log(chalk.cyan('Video URL:'), vid);
|
|
324
|
-
}
|
|
325
|
-
} catch (e) {}
|
|
326
|
-
|
|
327
|
-
// Sanitize large HTML blobs from output and print a concise summary.
|
|
328
|
-
function sanitize(obj) {
|
|
329
|
-
try {
|
|
330
|
-
const cloned = JSON.parse(JSON.stringify(obj));
|
|
331
|
-
const htmlKeys = ['html','mainHtml','pageHtml','fullHtml','renderedHtml','fetchedHtml','documentHtml','bodyHtml','responseBody'];
|
|
332
|
-
const walk = (o) => {
|
|
333
|
-
if (!o || typeof o !== 'object') return;
|
|
334
|
-
for (const k of Object.keys(o)) {
|
|
335
|
-
const v = o[k];
|
|
336
|
-
if (typeof v === 'string') {
|
|
337
|
-
const lowered = k.toLowerCase();
|
|
338
|
-
if (htmlKeys.includes(lowered) || (v.length > 1024 && /<\/?html|<!doctype html/i.test(v))) {
|
|
339
|
-
o[k] = `-- omitted HTML (length: ${v.length}) --`;
|
|
340
|
-
} else if (v.length > 2000) {
|
|
341
|
-
o[k] = v.slice(0, 2000) + `... (truncated, total ${v.length} chars)`;
|
|
342
|
-
}
|
|
343
|
-
} else if (typeof v === 'object') {
|
|
344
|
-
walk(v);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
};
|
|
348
|
-
walk(cloned);
|
|
349
|
-
return cloned;
|
|
350
|
-
} catch (e) { return obj; }
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Attempt to print a short human-friendly summary if available
|
|
354
|
-
try {
|
|
355
|
-
const summaryParts = [];
|
|
356
|
-
const results = data && (data.results || data); // support both shapes
|
|
357
|
-
if (results.performance || results.perf || results.performanceScore) {
|
|
358
|
-
const p = results.performance || results.perf || results.performanceScore;
|
|
359
|
-
summaryParts.push(`Performance: ${p}`);
|
|
360
|
-
}
|
|
361
|
-
if (results.accessibility) summaryParts.push(`Accessibility: ${results.accessibility}`);
|
|
362
|
-
if (results.seo) summaryParts.push(`SEO: ${results.seo}`);
|
|
363
|
-
if (results.bestPractices || results.best_practices) summaryParts.push(`Best Practices: ${results.bestPractices || results.best_practices}`);
|
|
364
|
-
if (results.security || results.securityGrade) summaryParts.push(`Security: ${results.security || results.securityGrade}`);
|
|
365
|
-
if (summaryParts.length) console.log(chalk.bold('Summary:'), summaryParts.join(' • '));
|
|
366
|
-
} catch (e) {}
|
|
367
198
|
|
|
368
|
-
console.log(
|
|
199
|
+
console.log(chalk.green('Scan finished. Full results:'));
|
|
200
|
+
console.log(JSON.stringify(data, null, 2));
|
|
369
201
|
return;
|
|
370
202
|
}
|
|
371
203
|
|