subto 6.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.
@@ -40,7 +40,7 @@ Commands
40
40
  {
41
41
  "url": "https://example.com",
42
42
  "source": "cli",
43
- "client": { "name": "subto-cli", "version": "5.0.0" }
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-5.0.0.tgz
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-5.0.0.tgz
107
+ npm install -g ./dist/subto-8.0.0.tgz
108
108
  ```
@@ -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: '5.0.0' };
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. Use Node 18+');
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; }
@@ -91,7 +91,7 @@ function printScanSummary(obj) {
91
91
 
92
92
  async function run(argv) {
93
93
  const program = new Command();
94
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '5.0.0');
94
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '7.0.0');
95
95
 
96
96
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
97
97
  try {
@@ -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
- const cfg = await readConfig(); if (!cfg || !cfg.apiKey) { console.error(chalk.red('Missing API key. Run:'), chalk.cyan('subto login')); process.exit(1); }
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
- 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
- }
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
- // 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.'));
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
- 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
- let recheckedAfterTerminal = false;
157
- while (true) {
158
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
159
- const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
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
- let data = null;
170
- try { data = await r.json(); } catch (e) { data = null; }
171
-
172
- if (data) {
173
- if (data.queuePosition !== undefined) console.log(chalk.cyan('Queue position:'), data.queuePosition);
174
- if (data.progress !== undefined) console.log(chalk.cyan('Progress:'), String(data.progress) + '%');
175
- const rawStatus = data.status !== undefined && data.status !== null ? String(data.status) : '';
176
- const displayStatus = rawStatus.trim();
177
- if (displayStatus) console.log(chalk.cyan('Status:'), displayStatus);
178
- if (data.stage) console.log(chalk.dim('Stage:'), data.stage);
179
- } else {
180
- console.log(chalk.dim('Waiting for server response...'));
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
- const statusStr = data && data.status ? String(data.status).toLowerCase().trim() : '';
184
- const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
185
- const done = statusStr && terminalStates.includes(statusStr);
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
- 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) {}
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
- await sleep(interval);
207
- }
224
+ await sleep(interval);
208
225
  }
226
+ }
209
227
 
210
- // Default (no wait): pretty summary
211
- printScanSummary(resp.body);
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,24 +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
- const CLIENT_META = { name: 'subto-cli', version: '5.0.0' };
13
+ const DEFAULT_API_BASE = 'https://subto.one/api/v1';
14
+ const CLIENT_META = { name: 'subto-cli', version: '8.0.0' };
15
15
  const cp = require('child_process');
16
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
+
17
29
  // Load local CLI .env if present (safe, optional)
18
30
  try {
19
31
  const dotenvPath = path.join(__dirname, '.env');
20
32
  const fsSync = require('fs');
21
33
  if (fsSync.existsSync(dotenvPath)) {
22
34
  try {
23
- const dotenv = require('dotenv');
24
- dotenv.config({ path: dotenvPath });
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
+ }
25
44
  } catch (e) {
26
45
  // dotenv not installed in this environment; ignore silently
27
46
  }
28
47
  }
29
48
  } catch (e) { /* non-fatal */ }
30
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
+
31
76
  function configFilePath() { return CONFIG_PATH; }
32
77
 
33
78
  async function readConfig() {
@@ -45,6 +90,47 @@ async function writeConfig(obj) {
45
90
  await fs.rename(tmp, configFilePath());
46
91
  }
47
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
+
48
134
  async function promptHidden(prompt) {
49
135
  if (!process.stdin.isTTY) throw new Error('Interactive prompt required');
50
136
  return new Promise((resolve, reject) => {
@@ -81,8 +167,8 @@ function validateUrl(input) {
81
167
  }
82
168
 
83
169
  async function postScan(url, apiKey) {
84
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
85
- const endpoint = new URL('/api/v1/scan', base).toString();
170
+ // Use normalized API base
171
+ const endpoint = new URL('scan', SUBTO_API_BASE_SLASH).toString();
86
172
  const body = { url, source: 'cli', client: CLIENT_META };
87
173
  const fetchFn = global.fetch;
88
174
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
@@ -185,7 +271,7 @@ async function printFullReport(data) {
185
271
 
186
272
  // Video link
187
273
  if (scan.videoUrl || scan.videoPath || scan.hasVideo) {
188
- const vurl = scan.videoUrl || ((scan.videoPath) ? `${process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE}/video/${scan.scanId || scan.id}` : null);
274
+ const vurl = scan.videoUrl || ((scan.videoPath) ? `${SUBTO_HOST_BASE}/video/${scan.scanId || scan.id}` : null);
189
275
  if (vurl) {
190
276
  // attempt to HEAD the video URL to detect expiry / 404
191
277
  let note = '';
@@ -207,7 +293,7 @@ async function printFullReport(data) {
207
293
 
208
294
  // Helpful interactive links (browser-based) — point to server-side chat and video endpoints
209
295
  try {
210
- const base = (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '');
296
+ const base = SUBTO_HOST_BASE;
211
297
  const id = scan.scanId || scan.id;
212
298
  if (id) {
213
299
  console.log(chalk.bold.underline('\nInteractive Links'));
@@ -319,7 +405,7 @@ function summarizeScanForPrompt(scan){
319
405
  }
320
406
  const issues = scan.issues || (scan.results && scan.results.issues) || [];
321
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('; ')}`);
322
- if(scan.videoUrl || scan.videoPath) parts.push(`Video: ${scan.videoUrl || `${process.env.SUBTO_API_BASE_URL||DEFAULT_API_BASE}/video/${scan.scanId||scan.id}`}`);
408
+ if(scan.videoUrl || scan.videoPath) parts.push(`Video: ${scan.videoUrl || `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}`}`);
323
409
  return parts.join('\n');
324
410
  }
325
411
 
@@ -346,7 +432,7 @@ async function answerFromScan(scan, question){
346
432
  // Local heuristic fallback
347
433
  const lq = q.toLowerCase();
348
434
  if(lq.includes('video')||lq.includes('record')){
349
- const url = scan.videoUrl || (scan.videoPath? `${process.env.SUBTO_API_BASE_URL||DEFAULT_API_BASE}/video/${scan.scanId||scan.id}` : null);
435
+ const url = scan.videoUrl || (scan.videoPath? `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}` : null);
350
436
  return url? `Session video: ${url}` : 'No session video available for this scan.';
351
437
  }
352
438
  if(lq.includes('issues')||lq.includes('problem')){
@@ -372,6 +458,10 @@ async function answerFromScan(scan, question){
372
458
 
373
459
  async function startChatREPL(scanData){
374
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.'));
375
465
  console.log(chalk.cyan('\nStarting interactive assistant. Type `exit` or Ctrl-D to quit.'));
376
466
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'AI> ' });
377
467
  rl.prompt();
@@ -391,8 +481,13 @@ async function startChatREPL(scanData){
391
481
 
392
482
  async function run(argv) {
393
483
  const program = new Command();
394
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '5.0.0');
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');
395
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)');
396
491
 
397
492
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
398
493
  try {
@@ -431,14 +526,20 @@ async function run(argv) {
431
526
  // - otherwise, poll by default when server returns a queued/started status
432
527
  // If server returned HTML (some proxies or web routes), avoid dumping raw HTML to terminal.
433
528
  // Try to recover a scanId from the HTML and fetch the JSON scan resource instead.
529
+ const VERBOSE = program.opts && program.opts().verbose;
434
530
  if (typeof resp.body === 'string' && resp.body.indexOf('<') !== -1) {
435
531
  // attempt to extract scan id
436
- const attemptId = extractScanIdFromHtml(resp.body);
532
+ const attemptId = extractScanIdFromHtml(resp.body);
437
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
+ }
438
540
  if (attemptId && typeof fetchFn === 'function') {
439
541
  try {
440
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
441
- const statusUrl = new URL(`/api/v1/scan/${attemptId}`, base).toString();
542
+ const statusUrl = new URL(`scan/${attemptId}`, SUBTO_API_BASE_SLASH).toString();
442
543
  const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
443
544
  if (r2 && r2.ok) {
444
545
  try { resp.body = await r2.json(); } catch (e) { /* leave as-is */ }
@@ -447,12 +548,28 @@ async function run(argv) {
447
548
  resp.body = { scanId: attemptId, status: 'accepted' };
448
549
  }
449
550
  } catch (e) {
450
- // couldn't fetch, fall through and keep original body but avoid printing HTML
451
- resp.body = { status: 'unknown', note: 'Server returned HTML and scanId could not be resolved.' };
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
+ }
452
561
  }
453
562
  } else {
454
- // No scan id found; replace with a safe message (do NOT print raw HTML)
455
- resp.body = { status: 'unknown', note: 'Server returned HTML. Use --wait or provide the scanId to fetch structured JSON.' };
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
+ }
456
573
  }
457
574
  }
458
575
 
@@ -474,7 +591,7 @@ async function run(argv) {
474
591
  process.exit(1);
475
592
  }
476
593
 
477
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
594
+ const base = SUBTO_HOST_BASE;
478
595
  const fetchFn = global.fetch;
479
596
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
480
597
 
@@ -491,9 +608,27 @@ async function run(argv) {
491
608
  // Track last server-provided percent to detect stalls
492
609
  let lastServerPercent = null;
493
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);
494
619
  let lastPollTs = Date.now();
495
620
  const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
496
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
+
497
632
  // Render a single-line progress: spinner + label + [###-------] NN%
498
633
  let lastRender = '';
499
634
  function computePercentFromData(d){
@@ -656,7 +791,7 @@ async function run(argv) {
656
791
  let data = null;
657
792
  startSpinner();
658
793
  while (true) {
659
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
794
+ const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
660
795
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
661
796
 
662
797
  if (r.status === 429) {
@@ -682,6 +817,174 @@ async function run(argv) {
682
817
  // render progress line
683
818
  renderLine(data);
684
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
+
685
988
  // consider terminal if status matches known terminal states OR percent maps to 100
686
989
  const pctNow = computePercentFromData(data);
687
990
  const done = (statusStr && terminalStates.includes(statusStr)) || (typeof pctNow === 'number' && pctNow === 100);
@@ -745,7 +1048,7 @@ async function run(argv) {
745
1048
 
746
1049
  // Send to server
747
1050
  try {
748
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
1051
+ const base = SUBTO_HOST_BASE;
749
1052
  const endpoint = new URL('/api/v1/upload', base).toString();
750
1053
  const fetchFn = global.fetch; if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
751
1054
  const body = { files: samples, meta: { collected: collected.length, totalBytes } };
@@ -764,7 +1067,7 @@ async function run(argv) {
764
1067
  const j = await r.json(); console.log(chalk.green('Upload queued:'), j.uploadId, 'scanId:', j.scanId, 'expiresAt:', new Date(j.expiresAt).toString());
765
1068
  if (opts.wait) {
766
1069
  // Poll the scan resource until completed (reuse existing polling behavior)
767
- const statusUrl = new URL(`/api/v1/scan/${j.scanId}`, base).toString();
1070
+ const statusUrl = new URL(`scan/${j.scanId}`, SUBTO_API_BASE_SLASH).toString();
768
1071
  const sleep = ms => new Promise(r=>setTimeout(r,ms));
769
1072
  while (true) {
770
1073
  const s = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
@@ -795,10 +1098,10 @@ async function run(argv) {
795
1098
  }
796
1099
 
797
1100
  if (!scanData && scanId) {
798
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
1101
+ const base = SUBTO_HOST_BASE;
799
1102
  const fetchFn = global.fetch;
800
1103
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
801
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
1104
+ const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
802
1105
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
803
1106
  if (!r.ok) { throw new Error(`Failed to fetch scan ${scanId}: ${r.status}`); }
804
1107
  scanData = await r.json();
@@ -816,6 +1119,13 @@ async function run(argv) {
816
1119
  .option('--max-files <n>', 'Maximum number of files to include', '300')
817
1120
  .option('--max-bytes <n>', 'Maximum total bytes to include', String(5 * 1024 * 1024))
818
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
+ }
819
1129
  try {
820
1130
  const target = dir ? path.resolve(dir) : process.cwd();
821
1131
  const maxFiles = parseInt(opts.maxFiles || opts.maxFiles === 0 ? opts.maxFiles : opts.maxFiles, 10) || parseInt(opts.maxFiles || 300, 10) || 300;
@@ -916,44 +1226,78 @@ async function run(argv) {
916
1226
 
917
1227
  // Store a local OpenRouter key + model for client-side AI calls (kept in ~/.subto)
918
1228
  program
919
- .command('upload key')
1229
+ .command('upload key [key]')
920
1230
  .description('Store a local OpenRouter API key and model for analysis (kept locally only)')
921
- .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) => {
922
1244
  try {
923
- const key = await promptHidden('OpenRouter API key: ');
924
- if (!key || !key.trim()) { console.log('Aborted.'); return; }
925
- // Ask for preferred model
926
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
927
- const model = await new Promise(res => rl.question('Model (e.g. openai/gpt-oss-120b:free): ', a => { rl.close(); res(a && a.trim()); }));
928
- const chosenModel = model && model.length ? model : 'openai/gpt-oss-120b:free';
929
-
930
- // Try to validate model exists (best-effort). If validation fails, ask user to confirm saving.
931
- let validated = false;
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.'));
1253
+ }
932
1254
  const fetchFn = global.fetch;
933
- if (typeof fetchFn === 'function') {
934
- try {
935
- const res = await fetchFn('https://openrouter.ai/api/v1/models', { headers: { 'Authorization': `Bearer ${key}` } });
936
- if (res && res.ok) {
937
- const jd = await res.json().catch(()=>null);
938
- const modelsList = jd && (jd.models || jd.data || jd) ;
939
- const names = Array.isArray(modelsList) ? modelsList.map(m => m.id || m.name || m.model || m.modelId).filter(Boolean) : [];
940
- if (names.length && names.includes(chosenModel)) validated = true;
941
- }
942
- } catch (e) { /* ignore network errors */ }
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);
943
1267
  }
1268
+ } catch (e) { console.error(chalk.red('Failed to push key:'), e && e.message ? e.message : String(e)); process.exit(1); }
1269
+ });
944
1270
 
945
- if (!validated) {
946
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
947
- 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()); }));
948
- if (answer !== 'y' && answer !== 'yes') { console.log('Aborted.'); return; }
1271
+ // Diagnostic helper: fetch video debug info for a scan id and present a friendly summary
1272
+ program
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) => {
1277
+ try {
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)');
949
1290
  }
950
-
951
- const cfg = await readConfig() || {};
952
- cfg.openrouterKey = key.trim();
953
- cfg.openrouterModel = chosenModel;
954
- await writeConfig(cfg);
955
- console.log(chalk.green('OpenRouter key and model saved to'), chalk.cyan(configFilePath()));
956
- } catch (e) { console.error(chalk.red('Failed to save key:'), e && e.message ? e.message : String(e)); process.exit(1); }
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); }
957
1301
  });
958
1302
 
959
1303
  if (!argv || argv.length === 0) { program.help(); return; }
@@ -972,9 +1316,9 @@ async function run(argv) {
972
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) {}
973
1317
  try {
974
1318
  if (!scanData) {
975
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE; const fetchFn = global.fetch;
1319
+ const base = SUBTO_HOST_BASE; const fetchFn = global.fetch;
976
1320
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
977
- const statusUrl = new URL(`/api/v1/scan/${answer}`, base).toString();
1321
+ const statusUrl = new URL(`scan/${answer}`, SUBTO_API_BASE_SLASH).toString();
978
1322
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
979
1323
  if (!r.ok) throw new Error(`Failed to fetch scan ${answer}: ${r.status}`);
980
1324
  scanData = await r.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "6.0.0",
3
+ "version": "8.0.0",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"