subto 7.0.0 → 8.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 +0 -51
- package/dist/package/README.md +3 -3
- package/dist/package/index.js +107 -90
- package/index.js +406 -163
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,57 +24,6 @@ Advanced features
|
|
|
24
24
|
|
|
25
25
|
- `subto upload [dir]` — run a local-only AI analysis on the sampled files (does not send files to the server). Useful when you want quick on-device feedback without uploading.
|
|
26
26
|
|
|
27
|
-
Examples (local testing and common workflows)
|
|
28
|
-
-------------------------------------------
|
|
29
|
-
|
|
30
|
-
- Save an OpenRouter key non-interactively:
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
node cli/bin/subto.js upload key sk-or-...YOURKEY...
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
- Save an OpenRouter key interactively:
|
|
37
|
-
|
|
38
|
-
```
|
|
39
|
-
node cli/bin/subto.js upload key
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
- Upload a directory but run the local AI stub (dry-run):
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
SUBTO_LOCAL_AI=1 node cli/bin/subto.js upload path/to/project
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
- Upload a directory to the server (requests server to expire uploads after 1 day):
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
node cli/bin/subto.js upload path/to/project
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
- Upload a directory and request a server scan (returns uploadId and scanId):
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
node cli/bin/subto.js scan upload path/to/project --wait
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
- Print/open the web AI chat for a scan:
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
node cli/bin/subto.js aichat <scanId>
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
- Quick smoke test (included):
|
|
67
|
-
|
|
68
|
-
```
|
|
69
|
-
bash test/smoke_cli_test.sh
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Notes
|
|
73
|
-
-----
|
|
74
|
-
|
|
75
|
-
- By default `upload` will send files to the server and request a 1-day expiry. Use `SUBTO_LOCAL_AI=1` to exercise the local AI dry-run behavior used during development and testing.
|
|
76
|
-
- After `subto scan` completes the CLI prints both the AI chat URL and the video URL (if available): `https://subto.one/aichat/<scanId>` and `https://subto.one/video/<scanId>`.
|
|
77
|
-
|
|
78
27
|
`.subtoignore` format
|
|
79
28
|
- One pattern per line.
|
|
80
29
|
- Lines starting with `#` are comments.
|
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": "8.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-8.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-8.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: '8.0.0' };
|
|
15
15
|
|
|
16
16
|
function configFilePath() { return CONFIG_PATH; }
|
|
17
17
|
|
|
@@ -69,8 +69,8 @@ async function postScan(url, apiKey) {
|
|
|
69
69
|
const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
70
70
|
const endpoint = new URL('/api/v1/scan', base).toString();
|
|
71
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.
|
|
72
|
+
const fetchFn = global.fetch || (function(){ try{ const u = require('undici'); if (u && typeof u.fetch === 'function') { global.fetch = u.fetch; return global.fetch; } } catch(e){} return null; })();
|
|
73
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Install Node 18+ or run `npm install undici`.');
|
|
74
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
75
|
const text = await res.text();
|
|
76
76
|
let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
@@ -111,105 +111,122 @@ async function run(argv) {
|
|
|
111
111
|
.option('--no-wait', 'Do not poll; return immediately')
|
|
112
112
|
.action(async (url, opts) => {
|
|
113
113
|
if (!validateUrl(url)) { console.error(chalk.red('Invalid URL. Provide a full URL including http:// or https://')); process.exit(1); }
|
|
114
|
-
|
|
114
|
+
|
|
115
|
+
// Load saved config (for API key)
|
|
116
|
+
const cfg = await readConfig();
|
|
117
|
+
if (!cfg || !cfg.apiKey) {
|
|
118
|
+
console.error(chalk.red('No API key configured. Run `subto login` to save your API key to ~/.subto/config.json'));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Submit scan request
|
|
123
|
+
let resp;
|
|
115
124
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
125
|
+
resp = await postScan(url, cfg.apiKey);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const msg = err && err.message ? err.message : String(err);
|
|
128
|
+
console.error(chalk.red('Network error:'), msg);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Handle non-2xx responses
|
|
133
|
+
if (!resp || typeof resp.status !== 'number') {
|
|
134
|
+
console.error(chalk.red('Unexpected response from server')); process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
137
|
+
console.error(chalk.red('Authentication failed. Check your API key with `subto login`.')); process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
if (resp.status === 429) {
|
|
140
|
+
let retrySeconds = null;
|
|
141
|
+
try { if (resp.body && typeof resp.body === 'object' && resp.body.retry_after) retrySeconds = resp.body.retry_after; } catch (e) {}
|
|
142
|
+
try { const ra = resp.headers && resp.headers.get && resp.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
|
|
143
|
+
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.'));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
147
|
+
let msg = `Request failed with status ${resp.status}`;
|
|
148
|
+
if (resp.body && typeof resp.body === 'object' && resp.body.error) msg += ': ' + resp.body.error;
|
|
149
|
+
console.error(chalk.red(msg)); process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Decide whether to poll
|
|
153
|
+
const serverStatus = resp.body && resp.body.status;
|
|
154
|
+
const serverIndicatesQueued = serverStatus && ['started', 'queued', 'pending', 'accepted'].includes(String(serverStatus).toLowerCase());
|
|
155
|
+
let shouldPoll;
|
|
156
|
+
if (opts.wait === false) shouldPoll = false;
|
|
157
|
+
else if (opts.wait === true) shouldPoll = true;
|
|
158
|
+
else shouldPoll = serverIndicatesQueued;
|
|
139
159
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
if (opts.json && !shouldPoll) {
|
|
161
|
+
console.log(JSON.stringify(resp.body, null, 2)); return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (shouldPoll) {
|
|
165
|
+
const scanId = (resp.body && (resp.body.scanId || resp.body.id || resp.body.scan_id));
|
|
166
|
+
if (!scanId) { console.error(chalk.red('Server did not return a scanId to poll.')); process.exit(1); }
|
|
167
|
+
|
|
168
|
+
const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
169
|
+
const fetchFn = global.fetch || (function(){ try{ const u = require('undici'); if (u && typeof u.fetch === 'function') { global.fetch = u.fetch; return global.fetch; } } catch(e){} return null; })();
|
|
170
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Install Node 18+ or run `npm install undici`.');
|
|
171
|
+
|
|
172
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
173
|
+
let interval = 5000;
|
|
174
|
+
console.log(chalk.blue('Queued scan. Polling for progress...'));
|
|
175
|
+
|
|
176
|
+
let recheckedAfterTerminal = false;
|
|
177
|
+
while (true) {
|
|
178
|
+
const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
|
|
179
|
+
const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
180
|
+
|
|
181
|
+
if (r.status === 429) {
|
|
182
|
+
let retrySeconds = null;
|
|
183
|
+
try { const j = await r.json(); if (j && j.retry_after) retrySeconds = j.retry_after; } catch (e) {}
|
|
184
|
+
try { const ra = r.headers.get && r.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
|
|
185
|
+
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.'));
|
|
145
186
|
process.exit(1);
|
|
146
187
|
}
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (r.status === 429) {
|
|
162
|
-
let retrySeconds = null;
|
|
163
|
-
try { const j = await r.json(); if (j && j.retry_after) retrySeconds = j.retry_after; } catch (e) {}
|
|
164
|
-
try { const ra = r.headers.get && r.headers.get('retry-after'); if (!retrySeconds && ra) retrySeconds = parseInt(ra, 10); } catch (e) {}
|
|
165
|
-
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.'));
|
|
166
|
-
process.exit(1);
|
|
167
|
-
}
|
|
189
|
+
let data = null;
|
|
190
|
+
try { data = await r.json(); } catch (e) { data = null; }
|
|
191
|
+
|
|
192
|
+
if (data) {
|
|
193
|
+
if (data.queuePosition !== undefined) console.log(chalk.cyan('Queue position:'), data.queuePosition);
|
|
194
|
+
if (data.progress !== undefined) console.log(chalk.cyan('Progress:'), String(data.progress) + '%');
|
|
195
|
+
const rawStatus = data.status !== undefined && data.status !== null ? String(data.status) : '';
|
|
196
|
+
const displayStatus = rawStatus.trim();
|
|
197
|
+
if (displayStatus) console.log(chalk.cyan('Status:'), displayStatus);
|
|
198
|
+
if (data.stage) console.log(chalk.dim('Stage:'), data.stage);
|
|
199
|
+
} else {
|
|
200
|
+
console.log(chalk.dim('Waiting for server response...'));
|
|
201
|
+
}
|
|
168
202
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
203
|
+
const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
|
|
204
|
+
const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
|
|
205
|
+
const done = statusStr && terminalStates.includes(statusStr);
|
|
206
|
+
|
|
207
|
+
if (done) {
|
|
208
|
+
const payloadKeys = data ? Object.keys(data).filter(k => !['status','queuePosition','progress','stage'].includes(k)) : [];
|
|
209
|
+
const hasPayload = payloadKeys.length > 0;
|
|
210
|
+
if (!hasPayload && !recheckedAfterTerminal) {
|
|
211
|
+
recheckedAfterTerminal = true;
|
|
212
|
+
console.log(chalk.yellow('Status is terminal but results not yet available — rechecking shortly...'));
|
|
213
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
214
|
+
continue;
|
|
181
215
|
}
|
|
182
216
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
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
|
-
const payloadKeys = data ? Object.keys(data).filter(k => !['status','queuePosition','progress','stage'].includes(k)) : [];
|
|
191
|
-
const hasPayload = payloadKeys.length > 0;
|
|
192
|
-
if (!hasPayload && !recheckedAfterTerminal) {
|
|
193
|
-
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
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
console.log(chalk.green('Scan finished. Full results:'));
|
|
200
|
-
console.log(JSON.stringify(data, null, 2));
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
217
|
+
console.log(chalk.green('Scan finished. Full results:'));
|
|
218
|
+
console.log(JSON.stringify(data, null, 2));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
203
221
|
|
|
204
|
-
|
|
222
|
+
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) {}
|
|
205
223
|
|
|
206
|
-
|
|
207
|
-
}
|
|
224
|
+
await sleep(interval);
|
|
208
225
|
}
|
|
226
|
+
}
|
|
209
227
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
} catch (err) { const msg = err && err.message ? err.message : String(err); console.error(chalk.red('Network error:'), msg); process.exit(1); }
|
|
228
|
+
// Default (no wait): pretty summary
|
|
229
|
+
printScanSummary(resp.body);
|
|
213
230
|
});
|
|
214
231
|
|
|
215
232
|
if (!argv || argv.length === 0) { program.help(); return; }
|
package/index.js
CHANGED
|
@@ -10,29 +10,69 @@ const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
|
|
|
10
10
|
|
|
11
11
|
const CONFIG_DIR = path.join(os.homedir(), '.subto');
|
|
12
12
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
13
|
-
const DEFAULT_API_BASE = 'https://subto.one';
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
// prefer the package.json version when available
|
|
17
|
-
const pkg = require('./package.json');
|
|
18
|
-
if (pkg && pkg.version) CLIENT_META.version = String(pkg.version);
|
|
19
|
-
} catch (e) { /* keep default */ }
|
|
13
|
+
const DEFAULT_API_BASE = 'https://subto.one/api/v1';
|
|
14
|
+
const CLIENT_META = { name: 'subto-cli', version: '8.0.0' };
|
|
20
15
|
const cp = require('child_process');
|
|
21
16
|
|
|
17
|
+
// Normalize SUBTO API base so callers can set either
|
|
18
|
+
// - https://subto.one
|
|
19
|
+
// - https://subto.one/
|
|
20
|
+
// - https://subto.one/api/v1
|
|
21
|
+
// - https://subto.one/api/v1/
|
|
22
|
+
// All usages below can rely on `SUBTO_HOST_BASE` (no trailing slash, no /api/v1)
|
|
23
|
+
// and `SUBTO_API_BASE` (no trailing slash, includes /api/v1).
|
|
24
|
+
const RAW_SUBTO_BASE = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
25
|
+
const SUBTO_HOST_BASE = String(RAW_SUBTO_BASE).replace(/\/api\/v1\/?$/i, '').replace(/\/$/, '');
|
|
26
|
+
const SUBTO_API_BASE = SUBTO_HOST_BASE + '/api/v1';
|
|
27
|
+
const SUBTO_API_BASE_SLASH = SUBTO_API_BASE + '/';
|
|
28
|
+
|
|
22
29
|
// Load local CLI .env if present (safe, optional)
|
|
23
30
|
try {
|
|
24
31
|
const dotenvPath = path.join(__dirname, '.env');
|
|
25
32
|
const fsSync = require('fs');
|
|
26
33
|
if (fsSync.existsSync(dotenvPath)) {
|
|
27
34
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
// Load dotenv quietly to avoid third-party tip messages appearing in CLI output
|
|
36
|
+
const _console = { log: console.log, info: console.info, warn: console.warn };
|
|
37
|
+
console.log = console.info = console.warn = () => {};
|
|
38
|
+
try {
|
|
39
|
+
const dotenv = require('dotenv');
|
|
40
|
+
dotenv.config({ path: dotenvPath });
|
|
41
|
+
} finally {
|
|
42
|
+
console.log = _console.log; console.info = _console.info; console.warn = _console.warn;
|
|
43
|
+
}
|
|
30
44
|
} catch (e) {
|
|
31
45
|
// dotenv not installed in this environment; ignore silently
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
48
|
} catch (e) { /* non-fatal */ }
|
|
35
49
|
|
|
50
|
+
// Also try loading .env from current working directory and user home for convenience
|
|
51
|
+
try {
|
|
52
|
+
const fsSync = require('fs');
|
|
53
|
+
const dotenv = (() => { try { return require('dotenv'); } catch(e) { return null; } })();
|
|
54
|
+
if (dotenv) {
|
|
55
|
+
try {
|
|
56
|
+
// Load .env from CWD quietly
|
|
57
|
+
const cwdEnv = path.join(process.cwd(), '.env');
|
|
58
|
+
if (fsSync.existsSync(cwdEnv)) {
|
|
59
|
+
const _console = { log: console.log, info: console.info, warn: console.warn };
|
|
60
|
+
console.log = console.info = console.warn = () => {};
|
|
61
|
+
try { dotenv.config({ path: cwdEnv }); } finally { console.log = _console.log; console.info = _console.info; console.warn = _console.warn; }
|
|
62
|
+
}
|
|
63
|
+
} catch (e) {}
|
|
64
|
+
try {
|
|
65
|
+
// Load ~/.env quietly
|
|
66
|
+
const homeEnv = path.join(os.homedir(), '.env');
|
|
67
|
+
if (fsSync.existsSync(homeEnv)) {
|
|
68
|
+
const _console2 = { log: console.log, info: console.info, warn: console.warn };
|
|
69
|
+
console.log = console.info = console.warn = () => {};
|
|
70
|
+
try { dotenv.config({ path: homeEnv }); } finally { console.log = _console2.log; console.info = _console2.info; console.warn = _console2.warn; }
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {}
|
|
73
|
+
}
|
|
74
|
+
} catch (e) { /* ignore */ }
|
|
75
|
+
|
|
36
76
|
function configFilePath() { return CONFIG_PATH; }
|
|
37
77
|
|
|
38
78
|
async function readConfig() {
|
|
@@ -50,6 +90,47 @@ async function writeConfig(obj) {
|
|
|
50
90
|
await fs.rename(tmp, configFilePath());
|
|
51
91
|
}
|
|
52
92
|
|
|
93
|
+
// Interactive helper to store OpenRouter key + model into ~/.subto/config.json
|
|
94
|
+
async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
|
|
95
|
+
try {
|
|
96
|
+
let key = keyArg;
|
|
97
|
+
if (!key) {
|
|
98
|
+
key = await promptHidden('OpenRouter API key: ');
|
|
99
|
+
if (!key || !key.trim()) { console.log('Aborted.'); return; }
|
|
100
|
+
}
|
|
101
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
102
|
+
const model = modelArg || await new Promise(res => rl.question('Model (e.g. openai/gpt-oss-120b:free): ', a => { rl.close(); res(a && a.trim()); }));
|
|
103
|
+
const chosenModel = model && model.length ? model : 'openai/gpt-oss-120b:free';
|
|
104
|
+
|
|
105
|
+
// Best-effort validation
|
|
106
|
+
let validated = false;
|
|
107
|
+
const fetchFn = global.fetch;
|
|
108
|
+
if (typeof fetchFn === 'function') {
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetchFn('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${key}` } });
|
|
111
|
+
if (res && res.ok) {
|
|
112
|
+
const jd = await res.json().catch(()=>null);
|
|
113
|
+
const modelsList = jd && (jd.models || jd.data || jd) ;
|
|
114
|
+
const names = Array.isArray(modelsList) ? modelsList.map(m => m.id || m.name || m.model || m.modelId).filter(Boolean) : [];
|
|
115
|
+
if (names.length && names.includes(chosenModel)) validated = true;
|
|
116
|
+
}
|
|
117
|
+
} catch (e) { /* ignore network errors */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!validated) {
|
|
121
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
122
|
+
const answer = await new Promise(res => rl2.question('Could not verify model with OpenRouter. Save anyway? (y/N): ', a => { rl2.close(); res(a && a.trim().toLowerCase()); }));
|
|
123
|
+
if (answer !== 'y' && answer !== 'yes') { console.log('Aborted.'); return; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cfg = await readConfig() || {};
|
|
127
|
+
cfg.openrouterKey = String(key).trim();
|
|
128
|
+
cfg.openrouterModel = chosenModel;
|
|
129
|
+
await writeConfig(cfg);
|
|
130
|
+
console.log(chalk.green('OpenRouter key and model saved to'), chalk.cyan(configFilePath()));
|
|
131
|
+
} catch (e) { console.error(chalk.red('Failed to save key:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
132
|
+
}
|
|
133
|
+
|
|
53
134
|
async function promptHidden(prompt) {
|
|
54
135
|
if (!process.stdin.isTTY) throw new Error('Interactive prompt required');
|
|
55
136
|
return new Promise((resolve, reject) => {
|
|
@@ -86,8 +167,8 @@ function validateUrl(input) {
|
|
|
86
167
|
}
|
|
87
168
|
|
|
88
169
|
async function postScan(url, apiKey) {
|
|
89
|
-
|
|
90
|
-
const endpoint = new URL('
|
|
170
|
+
// Use normalized API base
|
|
171
|
+
const endpoint = new URL('scan', SUBTO_API_BASE_SLASH).toString();
|
|
91
172
|
const body = { url, source: 'cli', client: CLIENT_META };
|
|
92
173
|
const fetchFn = global.fetch;
|
|
93
174
|
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
@@ -190,7 +271,7 @@ async function printFullReport(data) {
|
|
|
190
271
|
|
|
191
272
|
// Video link
|
|
192
273
|
if (scan.videoUrl || scan.videoPath || scan.hasVideo) {
|
|
193
|
-
const vurl = scan.videoUrl || ((scan.videoPath) ? `${
|
|
274
|
+
const vurl = scan.videoUrl || ((scan.videoPath) ? `${SUBTO_HOST_BASE}/video/${scan.scanId || scan.id}` : null);
|
|
194
275
|
if (vurl) {
|
|
195
276
|
// attempt to HEAD the video URL to detect expiry / 404
|
|
196
277
|
let note = '';
|
|
@@ -212,7 +293,7 @@ async function printFullReport(data) {
|
|
|
212
293
|
|
|
213
294
|
// Helpful interactive links (browser-based) — point to server-side chat and video endpoints
|
|
214
295
|
try {
|
|
215
|
-
const base =
|
|
296
|
+
const base = SUBTO_HOST_BASE;
|
|
216
297
|
const id = scan.scanId || scan.id;
|
|
217
298
|
if (id) {
|
|
218
299
|
console.log(chalk.bold.underline('\nInteractive Links'));
|
|
@@ -324,7 +405,7 @@ function summarizeScanForPrompt(scan){
|
|
|
324
405
|
}
|
|
325
406
|
const issues = scan.issues || (scan.results && scan.results.issues) || [];
|
|
326
407
|
if(Array.isArray(issues) && issues.length) parts.push(`Issues: ${issues.slice(0,10).map(i=>i.title||i.message||i.rule||i.id).join('; ')}`);
|
|
327
|
-
if(scan.videoUrl || scan.videoPath) parts.push(`Video: ${scan.videoUrl || `${
|
|
408
|
+
if(scan.videoUrl || scan.videoPath) parts.push(`Video: ${scan.videoUrl || `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}`}`);
|
|
328
409
|
return parts.join('\n');
|
|
329
410
|
}
|
|
330
411
|
|
|
@@ -351,7 +432,7 @@ async function answerFromScan(scan, question){
|
|
|
351
432
|
// Local heuristic fallback
|
|
352
433
|
const lq = q.toLowerCase();
|
|
353
434
|
if(lq.includes('video')||lq.includes('record')){
|
|
354
|
-
const url = scan.videoUrl || (scan.videoPath? `${
|
|
435
|
+
const url = scan.videoUrl || (scan.videoPath? `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}` : null);
|
|
355
436
|
return url? `Session video: ${url}` : 'No session video available for this scan.';
|
|
356
437
|
}
|
|
357
438
|
if(lq.includes('issues')||lq.includes('problem')){
|
|
@@ -377,6 +458,10 @@ async function answerFromScan(scan, question){
|
|
|
377
458
|
|
|
378
459
|
async function startChatREPL(scanData){
|
|
379
460
|
if(!process.stdin.isTTY){ console.log(chalk.yellow('Interactive chat not available in non-interactive terminal.')); return; }
|
|
461
|
+
// Report whether an AI key is configured (helps users debug why assistant may be limited)
|
|
462
|
+
const aiKeyPresent = Boolean(process.env.OPENAI_API_KEY || process.env.AI_API_KEY || process.env.OPENROUTER_API_KEY);
|
|
463
|
+
if (!aiKeyPresent) console.log(chalk.yellow('\nNo external AI API key found in environment. Assistant will run in local fallback mode (heuristic answers).'));
|
|
464
|
+
else console.log(chalk.cyan('\nAI API key found — assistant will call the configured model.'));
|
|
380
465
|
console.log(chalk.cyan('\nStarting interactive assistant. Type `exit` or Ctrl-D to quit.'));
|
|
381
466
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'AI> ' });
|
|
382
467
|
rl.prompt();
|
|
@@ -396,8 +481,13 @@ async function startChatREPL(scanData){
|
|
|
396
481
|
|
|
397
482
|
async function run(argv) {
|
|
398
483
|
const program = new Command();
|
|
399
|
-
program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '
|
|
484
|
+
program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.0');
|
|
485
|
+
program.option('-v, --verbose', 'Show verbose HTTP logs');
|
|
400
486
|
program.option('--chat', 'Start local AI assistant (no command required)');
|
|
487
|
+
program.option('--no-auto-skip', 'Disable automatic skipping of external APIs when scans appear stuck');
|
|
488
|
+
program.option('--skip-prompt-ms <n>', 'Milliseconds before prompting to skip external APIs (default 15000)');
|
|
489
|
+
program.option('--skip-countdown-ms <n>', 'Milliseconds countdown before auto-skip after prompt (default 10000)');
|
|
490
|
+
program.option('--skip-force-ms <n>', 'Milliseconds before forcing auto-skip after prompt (default 15000)');
|
|
401
491
|
|
|
402
492
|
program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
|
|
403
493
|
try {
|
|
@@ -436,14 +526,20 @@ async function run(argv) {
|
|
|
436
526
|
// - otherwise, poll by default when server returns a queued/started status
|
|
437
527
|
// If server returned HTML (some proxies or web routes), avoid dumping raw HTML to terminal.
|
|
438
528
|
// Try to recover a scanId from the HTML and fetch the JSON scan resource instead.
|
|
529
|
+
const VERBOSE = program.opts && program.opts().verbose;
|
|
439
530
|
if (typeof resp.body === 'string' && resp.body.indexOf('<') !== -1) {
|
|
440
531
|
// attempt to extract scan id
|
|
441
|
-
|
|
532
|
+
const attemptId = extractScanIdFromHtml(resp.body);
|
|
442
533
|
const fetchFn = global.fetch;
|
|
534
|
+
if (VERBOSE) {
|
|
535
|
+
try {
|
|
536
|
+
console.log(chalk.dim(`Response status: ${resp.status}`));
|
|
537
|
+
if (resp.headers && typeof resp.headers.forEach === 'function') resp.headers.forEach((v,k)=>console.log(chalk.dim(`${k}: ${v}`)));
|
|
538
|
+
} catch (e) {}
|
|
539
|
+
}
|
|
443
540
|
if (attemptId && typeof fetchFn === 'function') {
|
|
444
541
|
try {
|
|
445
|
-
const
|
|
446
|
-
const statusUrl = new URL(`/api/v1/scan/${attemptId}`, base).toString();
|
|
542
|
+
const statusUrl = new URL(`scan/${attemptId}`, SUBTO_API_BASE_SLASH).toString();
|
|
447
543
|
const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
448
544
|
if (r2 && r2.ok) {
|
|
449
545
|
try { resp.body = await r2.json(); } catch (e) { /* leave as-is */ }
|
|
@@ -452,12 +548,28 @@ async function run(argv) {
|
|
|
452
548
|
resp.body = { scanId: attemptId, status: 'accepted' };
|
|
453
549
|
}
|
|
454
550
|
} catch (e) {
|
|
455
|
-
// couldn't fetch
|
|
456
|
-
|
|
551
|
+
// couldn't fetch; save the HTML to a temp file for inspection
|
|
552
|
+
try {
|
|
553
|
+
const tmpPath = path.join(os.tmpdir(), `subto-scan-${Date.now()}.html`);
|
|
554
|
+
await fs.writeFile(tmpPath, resp.body, 'utf8');
|
|
555
|
+
console.error(chalk.yellow(`Server returned HTML and scanId could not be resolved. Saved HTML to: ${tmpPath}`));
|
|
556
|
+
if (VERBOSE) console.error(chalk.dim('Open the file to inspect the server response and locate a scanId.'));
|
|
557
|
+
resp.body = { status: 'unknown', note: `Server returned HTML (saved to ${tmpPath}).` };
|
|
558
|
+
} catch (writeErr) {
|
|
559
|
+
resp.body = { status: 'unknown', note: 'Server returned HTML and scanId could not be resolved.' };
|
|
560
|
+
}
|
|
457
561
|
}
|
|
458
562
|
} else {
|
|
459
|
-
// No scan id found;
|
|
460
|
-
|
|
563
|
+
// No scan id found; save HTML to temp file and provide a helpful message
|
|
564
|
+
try {
|
|
565
|
+
const tmpPath = path.join(os.tmpdir(), `subto-scan-${Date.now()}.html`);
|
|
566
|
+
await fs.writeFile(tmpPath, resp.body, 'utf8');
|
|
567
|
+
console.error(chalk.yellow(`Server returned HTML. Saved response to: ${tmpPath}`));
|
|
568
|
+
if (VERBOSE) console.error(chalk.dim('You can open that file in a browser to inspect what the server returned.'));
|
|
569
|
+
resp.body = { status: 'unknown', note: `Server returned HTML (saved to ${tmpPath}). Use --wait or provide the scanId to fetch structured JSON.` };
|
|
570
|
+
} catch (writeErr) {
|
|
571
|
+
resp.body = { status: 'unknown', note: 'Server returned HTML. Use --wait or provide the scanId to fetch structured JSON.' };
|
|
572
|
+
}
|
|
461
573
|
}
|
|
462
574
|
}
|
|
463
575
|
|
|
@@ -479,7 +591,7 @@ async function run(argv) {
|
|
|
479
591
|
process.exit(1);
|
|
480
592
|
}
|
|
481
593
|
|
|
482
|
-
const base =
|
|
594
|
+
const base = SUBTO_HOST_BASE;
|
|
483
595
|
const fetchFn = global.fetch;
|
|
484
596
|
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
485
597
|
|
|
@@ -496,9 +608,27 @@ async function run(argv) {
|
|
|
496
608
|
// Track last server-provided percent to detect stalls
|
|
497
609
|
let lastServerPercent = null;
|
|
498
610
|
let lastServerChangeAt = Date.now();
|
|
611
|
+
let promptedSkipExternal = false;
|
|
612
|
+
let skipRequestedAt = null;
|
|
613
|
+
const globalOpts = (program && typeof program.opts === 'function') ? program.opts() : {};
|
|
614
|
+
const SKIP_PROMPT_MS = Number(globalOpts.skipPromptMs || process.env.SUBTO_CLI_SKIP_PROMPT_MS || '15000'); // prompt after 15s stuck at 50%
|
|
615
|
+
const SKIP_COUNTDOWN_MS = Number(globalOpts.skipCountdownMs || process.env.SUBTO_CLI_SKIP_COUNTDOWN_MS || '10000'); // countdown before auto-skip
|
|
616
|
+
// If the scan remains stuck even after the interactive countdown, force auto-skip after this extra window
|
|
617
|
+
const SKIP_FORCE_AFTER_MS = Number(globalOpts.skipForceMs || process.env.SUBTO_CLI_SKIP_FORCE_AFTER_MS || '15000'); // default 15s
|
|
618
|
+
const AUTO_SKIP = (typeof globalOpts.autoSkip !== 'undefined') ? Boolean(globalOpts.autoSkip) : (globalOpts.noAutoSkip ? false : true);
|
|
499
619
|
let lastPollTs = Date.now();
|
|
500
620
|
const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
|
|
501
621
|
|
|
622
|
+
function markSkipRequested(currentServerPct){
|
|
623
|
+
try{
|
|
624
|
+
skipRequestedAt = Date.now();
|
|
625
|
+
lastServerChangeAt = Date.now();
|
|
626
|
+
// nudge targetPercent forward but avoid jumping to 100 — leave room for analysis
|
|
627
|
+
const base = (typeof currentServerPct === 'number' && currentServerPct > 0) ? currentServerPct : (lastServerPercent || 50);
|
|
628
|
+
targetPercent = Math.max(targetPercent, Math.min(90, Math.round(base + 10)));
|
|
629
|
+
}catch(e){}
|
|
630
|
+
}
|
|
631
|
+
|
|
502
632
|
// Render a single-line progress: spinner + label + [###-------] NN%
|
|
503
633
|
let lastRender = '';
|
|
504
634
|
function computePercentFromData(d){
|
|
@@ -661,7 +791,7 @@ async function run(argv) {
|
|
|
661
791
|
let data = null;
|
|
662
792
|
startSpinner();
|
|
663
793
|
while (true) {
|
|
664
|
-
|
|
794
|
+
const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
|
|
665
795
|
const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
666
796
|
|
|
667
797
|
if (r.status === 429) {
|
|
@@ -687,6 +817,174 @@ async function run(argv) {
|
|
|
687
817
|
// render progress line
|
|
688
818
|
renderLine(data);
|
|
689
819
|
|
|
820
|
+
// Stall detection: update lastServerPercent timestamp when server percent changes
|
|
821
|
+
try {
|
|
822
|
+
const serverPct = computePercentFromData(data);
|
|
823
|
+
if (typeof serverPct === 'number') {
|
|
824
|
+
if (lastServerPercent === null || serverPct !== lastServerPercent) {
|
|
825
|
+
lastServerPercent = serverPct;
|
|
826
|
+
lastServerChangeAt = Date.now();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// If stuck at ~50% for a while, prompt user to skip external APIs
|
|
830
|
+
if (!promptedSkipExternal && typeof serverPct === 'number' && serverPct === 50) {
|
|
831
|
+
const stuckMs = Date.now() - lastServerChangeAt;
|
|
832
|
+
if (stuckMs > SKIP_PROMPT_MS) {
|
|
833
|
+
promptedSkipExternal = true;
|
|
834
|
+
var promptedSkipExternalAt = Date.now();
|
|
835
|
+
// If not interactive, behave as before and auto-request skip immediately.
|
|
836
|
+
if (!process.stdin || !process.stdin.isTTY) {
|
|
837
|
+
try {
|
|
838
|
+
stopSpinner();
|
|
839
|
+
if (AUTO_SKIP) {
|
|
840
|
+
console.log(chalk.yellow('\nNo progress detected on external APIs — requesting server to skip external API calls (auto).'));
|
|
841
|
+
const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
|
|
842
|
+
markSkipRequested(lastServerPercent);
|
|
843
|
+
await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
|
|
844
|
+
console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
|
|
845
|
+
} else {
|
|
846
|
+
console.log(chalk.yellow('\nNo progress detected on external APIs. Automatic skip is disabled.'));
|
|
847
|
+
}
|
|
848
|
+
} catch (e) {
|
|
849
|
+
console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
|
|
850
|
+
} finally {
|
|
851
|
+
startSpinner();
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
// Interactive TTY: show a countdown and allow single-key to skip now or continue waiting.
|
|
855
|
+
stopSpinner();
|
|
856
|
+
try {
|
|
857
|
+
const totalMs = SKIP_COUNTDOWN_MS;
|
|
858
|
+
let remaining = totalMs;
|
|
859
|
+
process.stdout.write('\n');
|
|
860
|
+
process.stdout.write(chalk.yellow(`No progress detected on external APIs. Auto-skip in ${Math.ceil(remaining/1000)}s. Press 's' to skip now, 'w' to continue waiting.`));
|
|
861
|
+
|
|
862
|
+
// Ensure stdin in raw mode so we can capture single key presses
|
|
863
|
+
const stdin = process.stdin;
|
|
864
|
+
stdin.resume();
|
|
865
|
+
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
|
|
866
|
+
|
|
867
|
+
let resolved = false;
|
|
868
|
+
|
|
869
|
+
const cleanup = () => {
|
|
870
|
+
try {
|
|
871
|
+
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
|
|
872
|
+
} catch (e) {}
|
|
873
|
+
try { stdin.pause(); } catch (e) {}
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
const onKey = async (buf) => {
|
|
877
|
+
if (resolved) return;
|
|
878
|
+
const ch = String(buf || '').toLowerCase();
|
|
879
|
+
if (ch === '\u0003') {
|
|
880
|
+
// Ctrl+C - restore and exit
|
|
881
|
+
cleanup();
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
if (ch === 's') {
|
|
885
|
+
resolved = true;
|
|
886
|
+
stdin.removeListener('data', onKey);
|
|
887
|
+
cleanup();
|
|
888
|
+
// send skip now
|
|
889
|
+
try {
|
|
890
|
+
console.log(chalk.yellow('\nSkip requested by user.'));
|
|
891
|
+
const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
|
|
892
|
+
markSkipRequested(serverPct);
|
|
893
|
+
await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
|
|
894
|
+
console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
|
|
895
|
+
} catch (e) {
|
|
896
|
+
console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
|
|
897
|
+
} finally {
|
|
898
|
+
startSpinner();
|
|
899
|
+
}
|
|
900
|
+
} else if (ch === 'w') {
|
|
901
|
+
// continue waiting
|
|
902
|
+
resolved = true;
|
|
903
|
+
stdin.removeListener('data', onKey);
|
|
904
|
+
cleanup();
|
|
905
|
+
promptedSkipExternal = false;
|
|
906
|
+
lastServerChangeAt = Date.now();
|
|
907
|
+
console.log(chalk.dim('\nContinuing to wait for external API calls...'));
|
|
908
|
+
startSpinner();
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
stdin.on('data', onKey);
|
|
913
|
+
|
|
914
|
+
// countdown interval to update message
|
|
915
|
+
const tick = 1000;
|
|
916
|
+
const intervalId = setInterval(() => {
|
|
917
|
+
remaining -= tick;
|
|
918
|
+
if (resolved) {
|
|
919
|
+
clearInterval(intervalId);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (remaining <= 0) {
|
|
923
|
+
clearInterval(intervalId);
|
|
924
|
+
stdin.removeListener('data', onKey);
|
|
925
|
+
cleanup();
|
|
926
|
+
// timeout reached - auto-skip
|
|
927
|
+
(async () => {
|
|
928
|
+
try {
|
|
929
|
+
if (AUTO_SKIP) {
|
|
930
|
+
console.log(chalk.yellow('\nAuto-skip timeout reached — requesting server to skip external API calls.'));
|
|
931
|
+
const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
|
|
932
|
+
markSkipRequested(lastServerPercent);
|
|
933
|
+
await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
|
|
934
|
+
console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
|
|
935
|
+
} else {
|
|
936
|
+
console.log(chalk.yellow('\nAuto-skip timeout reached but automatic skip is disabled. Continuing to wait.'));
|
|
937
|
+
}
|
|
938
|
+
} catch (e) {
|
|
939
|
+
console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
|
|
940
|
+
} finally {
|
|
941
|
+
startSpinner();
|
|
942
|
+
}
|
|
943
|
+
})();
|
|
944
|
+
} else {
|
|
945
|
+
try {
|
|
946
|
+
readline.clearLine(process.stdout, 0);
|
|
947
|
+
readline.cursorTo(process.stdout, 0);
|
|
948
|
+
process.stdout.write(chalk.yellow(`Auto-skip in ${Math.ceil(remaining/1000)}s. Press 's' to skip now, 'w' to continue waiting.`));
|
|
949
|
+
} catch (e) {
|
|
950
|
+
// ignore render errors
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}, tick);
|
|
954
|
+
|
|
955
|
+
} catch (e) {
|
|
956
|
+
console.error(chalk.red('Prompt error:'), e && e.message ? e.message : e);
|
|
957
|
+
startSpinner();
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} catch (e) { /* ignore stall-detection errors */ }
|
|
963
|
+
|
|
964
|
+
// If user was prompted but no action taken and overall time exceeds force threshold, auto-skip
|
|
965
|
+
try {
|
|
966
|
+
if (promptedSkipExternal && promptedSkipExternalAt && (Date.now() - promptedSkipExternalAt) > SKIP_FORCE_AFTER_MS) {
|
|
967
|
+
// send skip once and reset flag
|
|
968
|
+
promptedSkipExternal = false;
|
|
969
|
+
try {
|
|
970
|
+
stopSpinner();
|
|
971
|
+
if (AUTO_SKIP) {
|
|
972
|
+
console.log(chalk.yellow('\nScan stuck at 50% for too long — auto-requesting skip of external APIs.'));
|
|
973
|
+
const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
|
|
974
|
+
markSkipRequested(lastServerPercent);
|
|
975
|
+
await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
|
|
976
|
+
console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
|
|
977
|
+
} else {
|
|
978
|
+
console.log(chalk.yellow('\nScan stuck at 50% for too long but automatic skip is disabled.'));
|
|
979
|
+
}
|
|
980
|
+
} catch (e) {
|
|
981
|
+
console.error(chalk.red('Failed to auto-request skip:'), e && e.message ? e.message : e);
|
|
982
|
+
} finally {
|
|
983
|
+
startSpinner();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
} catch (e) { /* ignore */ }
|
|
987
|
+
|
|
690
988
|
// consider terminal if status matches known terminal states OR percent maps to 100
|
|
691
989
|
const pctNow = computePercentFromData(data);
|
|
692
990
|
const done = (statusStr && terminalStates.includes(statusStr)) || (typeof pctNow === 'number' && pctNow === 100);
|
|
@@ -710,15 +1008,6 @@ async function run(argv) {
|
|
|
710
1008
|
console.log(chalk.green('Scan finished. Full results:'));
|
|
711
1009
|
if (opts && opts.json) console.log(JSON.stringify(data, null, 2));
|
|
712
1010
|
else await printFullReport(data);
|
|
713
|
-
// Print web links for AI chat and session video when available
|
|
714
|
-
try {
|
|
715
|
-
const sid = data && (data.scanId || data.id || data.scan_id);
|
|
716
|
-
if (sid) {
|
|
717
|
-
const baseUrl = (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
718
|
-
console.log('AI Chat URL:', baseUrl + `/aichat/${sid}`);
|
|
719
|
-
console.log('Video URL:', baseUrl + `/video/${sid}`);
|
|
720
|
-
}
|
|
721
|
-
} catch (e) { /* ignore */ }
|
|
722
1011
|
if (opts && opts.chat) await startChatREPL(data);
|
|
723
1012
|
return;
|
|
724
1013
|
}
|
|
@@ -731,14 +1020,6 @@ async function run(argv) {
|
|
|
731
1020
|
|
|
732
1021
|
// Default (no wait): pretty summary
|
|
733
1022
|
printScanSummary(resp.body);
|
|
734
|
-
try {
|
|
735
|
-
const sid = resp.body && (resp.body.scanId || resp.body.id || resp.body.scan_id);
|
|
736
|
-
if (sid) {
|
|
737
|
-
const baseUrl = (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '');
|
|
738
|
-
console.log('AI Chat URL:', baseUrl + `/aichat/${sid}`);
|
|
739
|
-
console.log('Video URL:', baseUrl + `/video/${sid}`);
|
|
740
|
-
}
|
|
741
|
-
} catch (e) { /* ignore */ }
|
|
742
1023
|
if (opts && opts.chat) await startChatREPL(resp.body);
|
|
743
1024
|
} catch (err) { const msg = err && err.message ? err.message : String(err); console.error(chalk.red('Network error:'), msg); process.exit(1); }
|
|
744
1025
|
});
|
|
@@ -767,10 +1048,10 @@ async function run(argv) {
|
|
|
767
1048
|
|
|
768
1049
|
// Send to server
|
|
769
1050
|
try {
|
|
770
|
-
const base =
|
|
1051
|
+
const base = SUBTO_HOST_BASE;
|
|
771
1052
|
const endpoint = new URL('/api/v1/upload', base).toString();
|
|
772
1053
|
const fetchFn = global.fetch; if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
773
|
-
const body = { files: samples, meta: { collected: collected.length, totalBytes
|
|
1054
|
+
const body = { files: samples, meta: { collected: collected.length, totalBytes } };
|
|
774
1055
|
const r = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}` }, body: JSON.stringify(body) });
|
|
775
1056
|
if (!r.ok) {
|
|
776
1057
|
const txt = await r.text().catch(()=>null);
|
|
@@ -786,7 +1067,7 @@ async function run(argv) {
|
|
|
786
1067
|
const j = await r.json(); console.log(chalk.green('Upload queued:'), j.uploadId, 'scanId:', j.scanId, 'expiresAt:', new Date(j.expiresAt).toString());
|
|
787
1068
|
if (opts.wait) {
|
|
788
1069
|
// Poll the scan resource until completed (reuse existing polling behavior)
|
|
789
|
-
const statusUrl = new URL(
|
|
1070
|
+
const statusUrl = new URL(`scan/${j.scanId}`, SUBTO_API_BASE_SLASH).toString();
|
|
790
1071
|
const sleep = ms => new Promise(r=>setTimeout(r,ms));
|
|
791
1072
|
while (true) {
|
|
792
1073
|
const s = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
@@ -817,10 +1098,10 @@ async function run(argv) {
|
|
|
817
1098
|
}
|
|
818
1099
|
|
|
819
1100
|
if (!scanData && scanId) {
|
|
820
|
-
const base =
|
|
1101
|
+
const base = SUBTO_HOST_BASE;
|
|
821
1102
|
const fetchFn = global.fetch;
|
|
822
1103
|
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
823
|
-
const statusUrl = new URL(
|
|
1104
|
+
const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
|
|
824
1105
|
const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
825
1106
|
if (!r.ok) { throw new Error(`Failed to fetch scan ${scanId}: ${r.status}`); }
|
|
826
1107
|
scanData = await r.json();
|
|
@@ -831,8 +1112,6 @@ async function run(argv) {
|
|
|
831
1112
|
} catch (e) { console.error(chalk.red('Error starting chat:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
832
1113
|
});
|
|
833
1114
|
|
|
834
|
-
|
|
835
|
-
|
|
836
1115
|
// Upload project files to AI for analysis. Respects `.subtoignore` and always ignores `.env`.
|
|
837
1116
|
program
|
|
838
1117
|
.command('upload [dir]')
|
|
@@ -840,41 +1119,14 @@ async function run(argv) {
|
|
|
840
1119
|
.option('--max-files <n>', 'Maximum number of files to include', '300')
|
|
841
1120
|
.option('--max-bytes <n>', 'Maximum total bytes to include', String(5 * 1024 * 1024))
|
|
842
1121
|
.action(async (dir, opts) => {
|
|
1122
|
+
// Support the legacy convenience `subto upload key` by dispatching
|
|
1123
|
+
// to the interactive OpenRouter key storage flow when caller used
|
|
1124
|
+
// the literal token `key` — avoids ambiguity with directory upload.
|
|
1125
|
+
if (String(dir || '').toLowerCase() === 'key') {
|
|
1126
|
+
await storeOpenRouterKeyInteractive();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
843
1129
|
try {
|
|
844
|
-
// If the user passed something that looks like an API key, reject it and
|
|
845
|
-
// instruct them to use the dedicated `subto upload key` command instead.
|
|
846
|
-
if (dir) {
|
|
847
|
-
const maybeKey = String(dir).trim();
|
|
848
|
-
const looksLikeKey = /^sk(-or)?-/i.test(maybeKey) || /^[A-Za-z0-9_-]{30,}$/.test(maybeKey);
|
|
849
|
-
if (looksLikeKey) {
|
|
850
|
-
console.error(chalk.red('It looks like you passed an API key to `subto upload`.'));
|
|
851
|
-
console.error(chalk.red('Do not pass API keys as the <dir> argument.'));
|
|
852
|
-
console.error('To store an OpenRouter key locally, run:', chalk.cyan('subto upload key'));
|
|
853
|
-
process.exit(1);
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// Support the pattern: `subto upload key <keyValue>` by handling when dir === 'key'.
|
|
858
|
-
if (dir === 'key') {
|
|
859
|
-
try {
|
|
860
|
-
const raw = process.argv.slice(2);
|
|
861
|
-
// raw usually looks like ['upload','key','<value>']
|
|
862
|
-
const idx = raw.indexOf('key');
|
|
863
|
-
const providedKey = (idx >= 0 && raw.length > idx + 1) ? raw[idx + 1] : null;
|
|
864
|
-
if (providedKey && (/^sk(-or)?-/i.test(providedKey) || /^[A-Za-z0-9_-]{30,}$/.test(providedKey))) {
|
|
865
|
-
const cfg = await readConfig() || {};
|
|
866
|
-
const chosenModel = process.env.AI_MODEL || (cfg && cfg.openrouterModel) || 'openai/gpt-oss-120b:free';
|
|
867
|
-
cfg.openrouterKey = providedKey.trim();
|
|
868
|
-
cfg.openrouterModel = chosenModel;
|
|
869
|
-
await writeConfig(cfg);
|
|
870
|
-
console.log(chalk.green('OpenRouter key and model saved to'), chalk.cyan(configFilePath()));
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
console.error(chalk.red('No API key value provided. Use `subto upload key <your-key>` or run `subto upload key` interactively.'));
|
|
874
|
-
process.exit(1);
|
|
875
|
-
} catch (e) { console.error(chalk.red('Failed to save key:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
876
|
-
}
|
|
877
|
-
|
|
878
1130
|
const target = dir ? path.resolve(dir) : process.cwd();
|
|
879
1131
|
const maxFiles = parseInt(opts.maxFiles || opts.maxFiles === 0 ? opts.maxFiles : opts.maxFiles, 10) || parseInt(opts.maxFiles || 300, 10) || 300;
|
|
880
1132
|
const maxBytes = parseInt(opts.maxBytes || opts.maxBytes === 0 ? opts.maxBytes : opts.maxBytes, 10) || (5 * 1024 * 1024);
|
|
@@ -961,37 +1213,11 @@ async function run(argv) {
|
|
|
961
1213
|
|
|
962
1214
|
const prompt = promptParts.join('\n\n');
|
|
963
1215
|
|
|
964
|
-
console.log(chalk.dim(`Collected ${collected.length} files, ${Math.round(totalBytes/1024)} KB total
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
console.log(chalk.bold('\nAI Analysis:'));
|
|
970
|
-
console.log(answer);
|
|
971
|
-
console.log(chalk.dim('\nNote: sensitive values are not printed; rotate any exposed keys if they were stored in the repo.'));
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
try {
|
|
976
|
-
const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
|
|
977
|
-
const endpoint = new URL('/api/v1/upload', base).toString();
|
|
978
|
-
const fetchFn = global.fetch; if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
979
|
-
const body = { files: samples, meta: { collected: collected.length, totalBytes, expiresInSeconds: 86400, startScan: false } };
|
|
980
|
-
const cfg = await readConfig();
|
|
981
|
-
const r = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg && cfg.apiKey}` }, body: JSON.stringify(body) });
|
|
982
|
-
if (!r.ok) {
|
|
983
|
-
const txt = await r.text().catch(()=>null);
|
|
984
|
-
console.error(chalk.red('Upload failed:'), r.status, txt || r.statusText);
|
|
985
|
-
process.exit(1);
|
|
986
|
-
}
|
|
987
|
-
const j = await r.json().catch(()=>null) || {};
|
|
988
|
-
console.log(chalk.green('Upload stored:'), j.uploadId ? `uploadId: ${j.uploadId}` : '');
|
|
989
|
-
if (j.scanId) console.log('scanId (server generated):', j.scanId);
|
|
990
|
-
if (j.scanId) console.log('AI Chat URL:', (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE) + `/aichat/${j.scanId}`);
|
|
991
|
-
return;
|
|
992
|
-
} catch (e) {
|
|
993
|
-
console.error(chalk.red('Upload request error:'), e && e.message ? e.message : String(e)); process.exit(1);
|
|
994
|
-
}
|
|
1216
|
+
console.log(chalk.dim(`Collected ${collected.length} files, ${Math.round(totalBytes/1024)} KB total. Sending summary to AI...`));
|
|
1217
|
+
const answer = await callOpenAI(prompt);
|
|
1218
|
+
console.log(chalk.bold('\nAI Analysis:'));
|
|
1219
|
+
console.log(answer);
|
|
1220
|
+
console.log(chalk.dim('\nNote: sensitive values are not printed; rotate any exposed keys if they were stored in the repo.'));
|
|
995
1221
|
} catch (e) {
|
|
996
1222
|
console.error(chalk.red('Upload failed:'), e && e.message ? e.message : String(e));
|
|
997
1223
|
process.exit(1);
|
|
@@ -1000,61 +1226,78 @@ async function run(argv) {
|
|
|
1000
1226
|
|
|
1001
1227
|
// Store a local OpenRouter key + model for client-side AI calls (kept in ~/.subto)
|
|
1002
1228
|
program
|
|
1003
|
-
.command('upload key')
|
|
1229
|
+
.command('upload key [key]')
|
|
1004
1230
|
.description('Store a local OpenRouter API key and model for analysis (kept locally only)')
|
|
1005
|
-
.action(async () => {
|
|
1231
|
+
.action(async (keyArg) => {
|
|
1232
|
+
await storeOpenRouterKeyInteractive(keyArg);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// Push a locally-stored OpenRouter/OpenAI key to a running server's internal endpoint.
|
|
1236
|
+
program
|
|
1237
|
+
.command('server-set-ai-key [key]')
|
|
1238
|
+
.description('Push AI API key to a running server via /internal/set-ai-key')
|
|
1239
|
+
.option('--server <url>', 'Server host base (default: ' + SUBTO_HOST_BASE + ')')
|
|
1240
|
+
.option('--secret <secret>', 'Internal task secret header (or set INTERNAL_TASK_SECRET env var)')
|
|
1241
|
+
.option('--provider <provider>', 'Provider name: openrouter|openai', 'openrouter')
|
|
1242
|
+
.option('--model <model>', 'Model identifier to request (OpenRouter model id)', 'openai/gpt-oss-120b:free')
|
|
1243
|
+
.action(async (keyArg, opts) => {
|
|
1006
1244
|
try {
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
let validated = false;
|
|
1016
|
-
const fetchFn = global.fetch;
|
|
1017
|
-
if (typeof fetchFn === 'function') {
|
|
1018
|
-
try {
|
|
1019
|
-
const res = await fetchFn('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${key}` } });
|
|
1020
|
-
if (res && res.ok) {
|
|
1021
|
-
const jd = await res.json().catch(()=>null);
|
|
1022
|
-
const modelsList = jd && (jd.models || jd.data || jd) ;
|
|
1023
|
-
const names = Array.isArray(modelsList) ? modelsList.map(m => m.id || m.name || m.model || m.modelId).filter(Boolean) : [];
|
|
1024
|
-
if (names.length && names.includes(chosenModel)) validated = true;
|
|
1025
|
-
}
|
|
1026
|
-
} catch (e) { /* ignore network errors */ }
|
|
1245
|
+
const cfg = await readConfig();
|
|
1246
|
+
let key = keyArg || (cfg && (cfg.openrouterKey || cfg.apiKey || cfg.openaiKey));
|
|
1247
|
+
if (!key) key = await promptHidden('API key to push to server: ');
|
|
1248
|
+
if (!key) { console.log(chalk.yellow('No key provided. Aborting.')); return; }
|
|
1249
|
+
const serverBase = opts.server || SUBTO_HOST_BASE;
|
|
1250
|
+
const secret = opts.secret || process.env.INTERNAL_TASK_SECRET || process.env.X_INTERNAL_TASK_SECRET;
|
|
1251
|
+
if (!secret) {
|
|
1252
|
+
console.log(chalk.yellow('Warning: no internal secret provided. The server will reject this request unless you supply --secret or set INTERNAL_TASK_SECRET.'));
|
|
1027
1253
|
}
|
|
1028
|
-
|
|
1029
|
-
if (
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1254
|
+
const fetchFn = global.fetch;
|
|
1255
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
1256
|
+
const endpoint = new URL('internal/set-ai-key', serverBase).toString();
|
|
1257
|
+
const body = { key: String(key).trim(), provider: String(opts.provider || 'openrouter'), model: String(opts.model || 'openai/gpt-oss-120b:free') };
|
|
1258
|
+
const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Task-Secret': secret || '' }, body: JSON.stringify(body) });
|
|
1259
|
+
const text = await res.text();
|
|
1260
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
1261
|
+
if (res.ok) {
|
|
1262
|
+
console.log(chalk.green('Server accepted the key. AI should be available immediately.'));
|
|
1263
|
+
if (data) console.log(JSON.stringify(data, null, 2));
|
|
1264
|
+
} else {
|
|
1265
|
+
console.error(chalk.red('Server rejected the request:'), res.status, data || text);
|
|
1266
|
+
process.exit(1);
|
|
1033
1267
|
}
|
|
1034
|
-
|
|
1035
|
-
const cfg = await readConfig() || {};
|
|
1036
|
-
cfg.openrouterKey = key.trim();
|
|
1037
|
-
cfg.openrouterModel = chosenModel;
|
|
1038
|
-
await writeConfig(cfg);
|
|
1039
|
-
console.log(chalk.green('OpenRouter key and model saved to'), chalk.cyan(configFilePath()));
|
|
1040
|
-
} catch (e) { console.error(chalk.red('Failed to save key:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
1268
|
+
} catch (e) { console.error(chalk.red('Failed to push key:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
1041
1269
|
});
|
|
1042
1270
|
|
|
1271
|
+
// Diagnostic helper: fetch video debug info for a scan id and present a friendly summary
|
|
1043
1272
|
program
|
|
1044
|
-
.command('
|
|
1045
|
-
.description('
|
|
1046
|
-
.
|
|
1273
|
+
.command('diag video <scanId>')
|
|
1274
|
+
.description('Retrieve video debug info for <scanId> from the server and summarize')
|
|
1275
|
+
.option('--server <url>', 'Server host base (default: ' + SUBTO_HOST_BASE + ')')
|
|
1276
|
+
.action(async (scanId, opts) => {
|
|
1047
1277
|
try {
|
|
1048
|
-
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1278
|
+
const serverBase = opts.server || SUBTO_HOST_BASE;
|
|
1279
|
+
const fetchFn = global.fetch;
|
|
1280
|
+
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
1281
|
+
const endpoint = new URL(`video/${scanId}/debug`, serverBase).toString();
|
|
1282
|
+
const res = await fetchFn(endpoint, { headers: { 'Accept': 'application/json' } });
|
|
1283
|
+
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
|
1284
|
+
const json = await res.json();
|
|
1285
|
+
console.log(chalk.bold('\nVideo diagnostic for:'), scanId);
|
|
1286
|
+
if (json && json.scan) {
|
|
1287
|
+
console.log(' URL:', json.scan.url || '(unknown)');
|
|
1288
|
+
console.log(' Status:', json.scan.status || json.scan.lastPhase || '(unknown)');
|
|
1289
|
+
console.log(' CompletedAt:', json.scan.completedAt ? new Date(json.scan.completedAt).toString() : '(not set)');
|
|
1290
|
+
}
|
|
1291
|
+
if (json && Array.isArray(json.checks)) {
|
|
1292
|
+
console.log(chalk.bold('\nChecked paths:'));
|
|
1293
|
+
for (const c of json.checks) {
|
|
1294
|
+
const ok = c.exists ? chalk.green('FOUND') : chalk.red('MISSING');
|
|
1295
|
+
console.log(` - ${c.path} : ${ok}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
console.log('\nSummary:', json && json.found ? chalk.green('Video available') : chalk.red('Video not found'));
|
|
1299
|
+
if (!json || !json.found) console.log(chalk.dim('If missing, verify pipeline logs and ensure GOOGLE_APPLICATION_CREDENTIALS and GCS_VIDEO_BUCKET are configured on the server.'));
|
|
1300
|
+
} catch (e) { console.error(chalk.red('Video diagnostic failed:'), e && e.message ? e.message : String(e)); process.exit(1); }
|
|
1058
1301
|
});
|
|
1059
1302
|
|
|
1060
1303
|
if (!argv || argv.length === 0) { program.help(); return; }
|
|
@@ -1073,9 +1316,9 @@ async function run(argv) {
|
|
|
1073
1316
|
try { if (await fs.stat(answer).then(s=>s.isFile()).catch(()=>false)) { const txt = await fs.readFile(answer,'utf8'); scanData = JSON.parse(txt); } } catch(e) {}
|
|
1074
1317
|
try {
|
|
1075
1318
|
if (!scanData) {
|
|
1076
|
-
const base =
|
|
1319
|
+
const base = SUBTO_HOST_BASE; const fetchFn = global.fetch;
|
|
1077
1320
|
if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
|
|
1078
|
-
|
|
1321
|
+
const statusUrl = new URL(`scan/${answer}`, SUBTO_API_BASE_SLASH).toString();
|
|
1079
1322
|
const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
|
|
1080
1323
|
if (!r.ok) throw new Error(`Failed to fetch scan ${answer}: ${r.status}`);
|
|
1081
1324
|
scanData = await r.json();
|