subto 0.1.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 +108 -0
- package/bin/subto.js +10 -0
- package/dist/subto-0.1.0.tgz +0 -0
- package/docs/cli-section.md +27 -0
- package/index.js +202 -0
- package/package.json +37 -0
- package/scripts/prepublish-check.js +16 -0
package/README.md
ADDED
|
@@ -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": "0.1.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-0.1.0.tgz
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You can download that file directly and install locally with:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install -g ./dist/subto-0.1.0.tgz
|
|
108
|
+
```
|
package/bin/subto.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Entrypoint for the `subto` CLI. Delegates to ../index.js */
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const cli = require(path.join(__dirname, '..', 'index.js'));
|
|
5
|
+
if (require.main === module) {
|
|
6
|
+
cli.run(process.argv.slice(2)).catch(err => {
|
|
7
|
+
console.error(err.message ? String(err.message) : String(err));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
Binary file
|
|
@@ -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.
|
package/index.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/* Subto CLI implementation */
|
|
2
|
+
const { Command } = require('commander');
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
// Chalk v5 is ESM — when required from CommonJS it may expose a default export.
|
|
8
|
+
const _chalk = require('chalk');
|
|
9
|
+
const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
|
|
10
|
+
|
|
11
|
+
const CONFIG_DIR = path.join(os.homedir(), '.subto');
|
|
12
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
+
const DEFAULT_API_BASE = 'https://subto.one';
|
|
14
|
+
const CLIENT_META = { name: 'subto-cli', version: '0.1.0' };
|
|
15
|
+
|
|
16
|
+
function configFilePath() { return CONFIG_PATH; }
|
|
17
|
+
|
|
18
|
+
async function readConfig() {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(configFilePath(), 'utf8');
|
|
21
|
+
return JSON.parse(content);
|
|
22
|
+
} catch (err) { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function writeConfig(obj) {
|
|
26
|
+
await fs.mkdir(CONFIG_DIR, { mode: 0o700, recursive: true });
|
|
27
|
+
const data = JSON.stringify(obj, null, 2);
|
|
28
|
+
const tmp = configFilePath() + '.tmp';
|
|
29
|
+
await fs.writeFile(tmp, data, { mode: 0o600 });
|
|
30
|
+
await fs.rename(tmp, configFilePath());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function promptHidden(prompt) {
|
|
34
|
+
if (!process.stdin.isTTY) throw new Error('Interactive prompt required');
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const stdin = process.stdin;
|
|
37
|
+
stdin.resume();
|
|
38
|
+
stdin.setRawMode(true);
|
|
39
|
+
let value = '';
|
|
40
|
+
process.stdout.write(prompt);
|
|
41
|
+
function onData(char) {
|
|
42
|
+
char = String(char);
|
|
43
|
+
const code = char.charCodeAt(0);
|
|
44
|
+
if (code === 13 || code === 10) {
|
|
45
|
+
stdin.removeListener('data', onData);
|
|
46
|
+
stdin.setRawMode(false);
|
|
47
|
+
process.stdout.write('\n');
|
|
48
|
+
resolve(value.trim());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (code === 3) {
|
|
52
|
+
stdin.removeListener('data', onData);
|
|
53
|
+
stdin.setRawMode(false);
|
|
54
|
+
reject(new Error('Aborted'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (code === 127 || code === 8) { value = value.slice(0, -1); process.stdout.clearLine(); process.stdout.cursorTo(0); process.stdout.write(prompt + '*'.repeat(value.length)); return; }
|
|
58
|
+
value += char; process.stdout.write('*');
|
|
59
|
+
}
|
|
60
|
+
stdin.on('data', onData);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateUrl(input) {
|
|
65
|
+
try { const u = new URL(input); if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; return true; } catch (err) { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function postScan(url, apiKey) {
|
|
69
|
+
const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
70
|
+
const endpoint = new URL('/api/v1/scan', base).toString();
|
|
71
|
+
const body = { url, source: 'cli', client: CLIENT_META };
|
|
72
|
+
const fetchFn = global.fetch;
|
|
73
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
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) });
|
|
75
|
+
const text = await res.text();
|
|
76
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
77
|
+
return { status: res.status, headers: res.headers, body: data };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printScanSummary(obj) {
|
|
81
|
+
if (!obj || typeof obj !== 'object') { console.log(obj); return; }
|
|
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(', ')));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function run(argv) {
|
|
93
|
+
const program = new Command();
|
|
94
|
+
program.name('subto').description('Subto CLI — wrapper around Subto.One API').version('0.1.0');
|
|
95
|
+
|
|
96
|
+
program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
|
|
97
|
+
try {
|
|
98
|
+
const apiKey = await promptHidden('API key: ');
|
|
99
|
+
if (!apiKey || !apiKey.trim()) throw new Error('No API key entered. Aborting.');
|
|
100
|
+
if (apiKey.length < 10) throw new Error('API key looks too short.');
|
|
101
|
+
await writeConfig({ apiKey: apiKey.trim() });
|
|
102
|
+
console.log(chalk.green('API key saved to'), chalk.cyan(configFilePath()));
|
|
103
|
+
} catch (err) { if (err && err.message === 'Aborted') { console.log('\n' + chalk.yellow('Login aborted.')); process.exit(1); } throw err; }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
program
|
|
107
|
+
.command('scan <url>')
|
|
108
|
+
.description('Request a scan for <url> via the Subto API')
|
|
109
|
+
.option('--json', 'Output raw JSON')
|
|
110
|
+
.option('--wait', 'Poll for completion and show progress')
|
|
111
|
+
.option('--no-wait', 'Do not poll; return immediately')
|
|
112
|
+
.action(async (url, opts) => {
|
|
113
|
+
if (!validateUrl(url)) { console.error(chalk.red('Invalid URL. Provide a full URL including http:// or https://')); process.exit(1); }
|
|
114
|
+
const cfg = await readConfig(); if (!cfg || !cfg.apiKey) { console.error(chalk.red('Missing API key. Run:'), chalk.cyan('subto login')); process.exit(1); }
|
|
115
|
+
try {
|
|
116
|
+
const resp = await postScan(url, cfg.apiKey);
|
|
117
|
+
if (resp.status === 429) {
|
|
118
|
+
let retrySeconds = null;
|
|
119
|
+
if (resp.body && typeof resp.body === 'object' && resp.body.retry_after) retrySeconds = resp.body.retry_after;
|
|
120
|
+
try { const ra = resp.headers.get && resp.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
|
|
121
|
+
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.'));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
if (resp.status < 200 || resp.status >= 300) { let msg = `Request failed with status ${resp.status}`; if (resp.body && typeof resp.body === 'object' && resp.body.error) msg += ': ' + resp.body.error; console.error(chalk.red(msg)); process.exit(1); }
|
|
125
|
+
|
|
126
|
+
// Decide whether to poll:
|
|
127
|
+
// - if user passed --wait -> poll
|
|
128
|
+
// - if user passed --no-wait -> do not poll
|
|
129
|
+
// - otherwise, poll by default when server returns a queued/started status
|
|
130
|
+
const serverStatus = resp.body && resp.body.status;
|
|
131
|
+
const serverIndicatesQueued = serverStatus && ['started', 'queued', 'pending', 'accepted'].includes(String(serverStatus).toLowerCase());
|
|
132
|
+
const shouldPoll = (!opts.noWait) && (opts.wait || serverIndicatesQueued);
|
|
133
|
+
|
|
134
|
+
// If user asked for JSON only and we're not polling, print and exit.
|
|
135
|
+
if (opts.json && !shouldPoll) {
|
|
136
|
+
console.log(JSON.stringify(resp.body, null, 2));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If we should poll, poll the scan resource until completion and print progress.
|
|
141
|
+
if (shouldPoll) {
|
|
142
|
+
const scanId = (resp.body && (resp.body.scanId || resp.body.id || resp.body.scan_id));
|
|
143
|
+
if (!scanId) {
|
|
144
|
+
console.error(chalk.red('Server did not return a scanId to poll.'));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
149
|
+
const fetchFn = global.fetch;
|
|
150
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
151
|
+
|
|
152
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
153
|
+
let interval = 5000;
|
|
154
|
+
console.log(chalk.blue('Queued scan. Polling for progress...'));
|
|
155
|
+
|
|
156
|
+
while (true) {
|
|
157
|
+
const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
|
|
158
|
+
const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
159
|
+
|
|
160
|
+
if (r.status === 429) {
|
|
161
|
+
let retrySeconds = null;
|
|
162
|
+
try { const j = await r.json(); if (j && j.retry_after) retrySeconds = j.retry_after; } catch (e) {}
|
|
163
|
+
try { const ra = r.headers.get && r.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
|
|
164
|
+
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.'));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let data = null;
|
|
169
|
+
try { data = await r.json(); } catch (e) { data = null; }
|
|
170
|
+
|
|
171
|
+
if (data) {
|
|
172
|
+
if (data.queuePosition !== undefined) console.log(chalk.cyan('Queue position:'), data.queuePosition);
|
|
173
|
+
if (data.progress !== undefined) console.log(chalk.cyan('Progress:'), String(data.progress) + '%');
|
|
174
|
+
if (data.status) console.log(chalk.cyan('Status:'), data.status);
|
|
175
|
+
if (data.stage) console.log(chalk.dim('Stage:'), data.stage);
|
|
176
|
+
} else {
|
|
177
|
+
console.log(chalk.dim('Waiting for server response...'));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const done = data && (data.status === 'finished' || data.status === 'completed' || data.status === 'done');
|
|
181
|
+
if (done) {
|
|
182
|
+
console.log(chalk.green('Scan finished. Full results:'));
|
|
183
|
+
console.log(JSON.stringify(data, null, 2));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try { const ra = r.headers.get && r.headers.get('retry-after'); if (ra) { const secs = parseInt(ra, 10); if (!Number.isNaN(secs)) interval = Math.max(interval, secs * 1000); } } catch (e) {}
|
|
188
|
+
|
|
189
|
+
await sleep(interval);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Default (no wait): pretty summary
|
|
194
|
+
printScanSummary(resp.body);
|
|
195
|
+
} catch (err) { const msg = err && err.message ? err.message : String(err); console.error(chalk.red('Network error:'), msg); process.exit(1); }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!argv || argv.length === 0) { program.help(); return; }
|
|
199
|
+
await program.parseAsync(argv, { from: 'user' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { run };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "subto",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Subto CLI — thin wrapper around the Subto.One API",
|
|
5
|
+
"bin": {
|
|
6
|
+
"subto": "bin/subto.js"
|
|
7
|
+
},
|
|
8
|
+
"preferGlobal": true,
|
|
9
|
+
"main": "bin/subto.js",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/",
|
|
13
|
+
"index.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"docs/",
|
|
16
|
+
"scripts/",
|
|
17
|
+
"dist/"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://subto.one",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://subto.one"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://subto.one"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"prepublishOnly": "node ./scripts/prepublish-check.js"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^11.0.0",
|
|
35
|
+
"chalk": "^5.3.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
function fail(msg) { console.error('prepublish-check:', msg); process.exit(1); }
|
|
5
|
+
const root = path.resolve(__dirname, '..');
|
|
6
|
+
const pkgPath = path.join(root, 'package.json');
|
|
7
|
+
if (!fs.existsSync(pkgPath)) fail('package.json not found');
|
|
8
|
+
let pkg;
|
|
9
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch (e) { fail('Failed to parse package.json: ' + e.message); }
|
|
10
|
+
if (!pkg.bin || !pkg.bin.subto) fail('package.json `bin` must contain a `subto` mapping');
|
|
11
|
+
if (pkg.bin.subto !== './bin/subto.js' && pkg.bin.subto !== 'bin/subto.js') fail('package.json `bin.subto` must be "./bin/subto.js"');
|
|
12
|
+
const entry = path.join(root, 'bin', 'subto.js');
|
|
13
|
+
if (!fs.existsSync(entry)) fail('CLI entry file bin/subto.js not found');
|
|
14
|
+
try { const st = fs.statSync(entry); const mode = st.mode | 0o100; fs.chmodSync(entry, mode); } catch (e) { console.warn('prepublish-check: warning: could not set executable bit:', e.message); }
|
|
15
|
+
console.log('prepublish-check: ok');
|
|
16
|
+
process.exit(0);
|