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 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);