subto 7.0.0 → 8.0.1

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 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.
@@ -40,7 +40,7 @@ Commands
40
40
  {
41
41
  "url": "https://example.com",
42
42
  "source": "cli",
43
- "client": { "name": "subto-cli", "version": "7.0.0" }
43
+ "client": { "name": "subto-cli", "version": "8.0.1" }
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-7.0.0.tgz
101
+ ./dist/subto-8.0.1.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-7.0.0.tgz
107
+ npm install -g ./dist/subto-8.0.1.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: '7.0.0' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '8.0.1' };
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; }
@@ -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,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
- let CLIENT_META = { name: 'subto-cli', version: '7.0.0' };
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.1' };
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
- const dotenv = require('dotenv');
29
- 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
+ }
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,7 +90,55 @@ 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) {
135
+ function isByteStringSafe(s){
136
+ if (typeof s !== 'string') return false;
137
+ for (let i = 0; i < s.length; i++) {
138
+ if (s.charCodeAt(i) > 255) return false;
139
+ }
140
+ return true;
141
+ }
54
142
  if (!process.stdin.isTTY) throw new Error('Interactive prompt required');
55
143
  return new Promise((resolve, reject) => {
56
144
  const stdin = process.stdin;
@@ -86,11 +174,15 @@ function validateUrl(input) {
86
174
  }
87
175
 
88
176
  async function postScan(url, apiKey) {
89
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
90
- const endpoint = new URL('/api/v1/scan', base).toString();
177
+ // Use normalized API base
178
+ const endpoint = new URL('scan', SUBTO_API_BASE_SLASH).toString();
91
179
  const body = { url, source: 'cli', client: CLIENT_META };
92
180
  const fetchFn = global.fetch;
93
181
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
182
+ // Validate header-safety to avoid undici/node fetch ByteString conversion errors
183
+ if (!isByteStringSafe(String(apiKey || ''))) {
184
+ throw new Error('API key contains unsupported characters (non-Latin-1). Re-run `subto login` and paste a plain ASCII API key.');
185
+ }
94
186
  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) });
95
187
  const text = await res.text();
96
188
  let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
@@ -190,7 +282,7 @@ async function printFullReport(data) {
190
282
 
191
283
  // Video link
192
284
  if (scan.videoUrl || scan.videoPath || scan.hasVideo) {
193
- const vurl = scan.videoUrl || ((scan.videoPath) ? `${process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE}/video/${scan.scanId || scan.id}` : null);
285
+ const vurl = scan.videoUrl || ((scan.videoPath) ? `${SUBTO_HOST_BASE}/video/${scan.scanId || scan.id}` : null);
194
286
  if (vurl) {
195
287
  // attempt to HEAD the video URL to detect expiry / 404
196
288
  let note = '';
@@ -212,7 +304,7 @@ async function printFullReport(data) {
212
304
 
213
305
  // Helpful interactive links (browser-based) — point to server-side chat and video endpoints
214
306
  try {
215
- const base = (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '');
307
+ const base = SUBTO_HOST_BASE;
216
308
  const id = scan.scanId || scan.id;
217
309
  if (id) {
218
310
  console.log(chalk.bold.underline('\nInteractive Links'));
@@ -324,7 +416,7 @@ function summarizeScanForPrompt(scan){
324
416
  }
325
417
  const issues = scan.issues || (scan.results && scan.results.issues) || [];
326
418
  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 || `${process.env.SUBTO_API_BASE_URL||DEFAULT_API_BASE}/video/${scan.scanId||scan.id}`}`);
419
+ if(scan.videoUrl || scan.videoPath) parts.push(`Video: ${scan.videoUrl || `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}`}`);
328
420
  return parts.join('\n');
329
421
  }
330
422
 
@@ -351,7 +443,7 @@ async function answerFromScan(scan, question){
351
443
  // Local heuristic fallback
352
444
  const lq = q.toLowerCase();
353
445
  if(lq.includes('video')||lq.includes('record')){
354
- const url = scan.videoUrl || (scan.videoPath? `${process.env.SUBTO_API_BASE_URL||DEFAULT_API_BASE}/video/${scan.scanId||scan.id}` : null);
446
+ const url = scan.videoUrl || (scan.videoPath? `${SUBTO_HOST_BASE}/video/${scan.scanId||scan.id}` : null);
355
447
  return url? `Session video: ${url}` : 'No session video available for this scan.';
356
448
  }
357
449
  if(lq.includes('issues')||lq.includes('problem')){
@@ -377,6 +469,10 @@ async function answerFromScan(scan, question){
377
469
 
378
470
  async function startChatREPL(scanData){
379
471
  if(!process.stdin.isTTY){ console.log(chalk.yellow('Interactive chat not available in non-interactive terminal.')); return; }
472
+ // Report whether an AI key is configured (helps users debug why assistant may be limited)
473
+ const aiKeyPresent = Boolean(process.env.OPENAI_API_KEY || process.env.AI_API_KEY || process.env.OPENROUTER_API_KEY);
474
+ if (!aiKeyPresent) console.log(chalk.yellow('\nNo external AI API key found in environment. Assistant will run in local fallback mode (heuristic answers).'));
475
+ else console.log(chalk.cyan('\nAI API key found — assistant will call the configured model.'));
380
476
  console.log(chalk.cyan('\nStarting interactive assistant. Type `exit` or Ctrl-D to quit.'));
381
477
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'AI> ' });
382
478
  rl.prompt();
@@ -396,8 +492,13 @@ async function startChatREPL(scanData){
396
492
 
397
493
  async function run(argv) {
398
494
  const program = new Command();
399
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '7.0.0');
495
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.1');
496
+ program.option('-v, --verbose', 'Show verbose HTTP logs');
400
497
  program.option('--chat', 'Start local AI assistant (no command required)');
498
+ program.option('--no-auto-skip', 'Disable automatic skipping of external APIs when scans appear stuck');
499
+ program.option('--skip-prompt-ms <n>', 'Milliseconds before prompting to skip external APIs (default 15000)');
500
+ program.option('--skip-countdown-ms <n>', 'Milliseconds countdown before auto-skip after prompt (default 10000)');
501
+ program.option('--skip-force-ms <n>', 'Milliseconds before forcing auto-skip after prompt (default 15000)');
401
502
 
402
503
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
403
504
  try {
@@ -436,14 +537,20 @@ async function run(argv) {
436
537
  // - otherwise, poll by default when server returns a queued/started status
437
538
  // If server returned HTML (some proxies or web routes), avoid dumping raw HTML to terminal.
438
539
  // Try to recover a scanId from the HTML and fetch the JSON scan resource instead.
540
+ const VERBOSE = program.opts && program.opts().verbose;
439
541
  if (typeof resp.body === 'string' && resp.body.indexOf('<') !== -1) {
440
542
  // attempt to extract scan id
441
- const attemptId = extractScanIdFromHtml(resp.body);
543
+ const attemptId = extractScanIdFromHtml(resp.body);
442
544
  const fetchFn = global.fetch;
545
+ if (VERBOSE) {
546
+ try {
547
+ console.log(chalk.dim(`Response status: ${resp.status}`));
548
+ if (resp.headers && typeof resp.headers.forEach === 'function') resp.headers.forEach((v,k)=>console.log(chalk.dim(`${k}: ${v}`)));
549
+ } catch (e) {}
550
+ }
443
551
  if (attemptId && typeof fetchFn === 'function') {
444
552
  try {
445
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
446
- const statusUrl = new URL(`/api/v1/scan/${attemptId}`, base).toString();
553
+ const statusUrl = new URL(`scan/${attemptId}`, SUBTO_API_BASE_SLASH).toString();
447
554
  const r2 = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
448
555
  if (r2 && r2.ok) {
449
556
  try { resp.body = await r2.json(); } catch (e) { /* leave as-is */ }
@@ -452,12 +559,28 @@ async function run(argv) {
452
559
  resp.body = { scanId: attemptId, status: 'accepted' };
453
560
  }
454
561
  } catch (e) {
455
- // couldn't fetch, fall through and keep original body but avoid printing HTML
456
- resp.body = { status: 'unknown', note: 'Server returned HTML and scanId could not be resolved.' };
562
+ // couldn't fetch; save the HTML to a temp file for inspection
563
+ try {
564
+ const tmpPath = path.join(os.tmpdir(), `subto-scan-${Date.now()}.html`);
565
+ await fs.writeFile(tmpPath, resp.body, 'utf8');
566
+ console.error(chalk.yellow(`Server returned HTML and scanId could not be resolved. Saved HTML to: ${tmpPath}`));
567
+ if (VERBOSE) console.error(chalk.dim('Open the file to inspect the server response and locate a scanId.'));
568
+ resp.body = { status: 'unknown', note: `Server returned HTML (saved to ${tmpPath}).` };
569
+ } catch (writeErr) {
570
+ resp.body = { status: 'unknown', note: 'Server returned HTML and scanId could not be resolved.' };
571
+ }
457
572
  }
458
573
  } else {
459
- // No scan id found; replace with a safe message (do NOT print raw HTML)
460
- resp.body = { status: 'unknown', note: 'Server returned HTML. Use --wait or provide the scanId to fetch structured JSON.' };
574
+ // No scan id found; save HTML to temp file and provide a helpful message
575
+ try {
576
+ const tmpPath = path.join(os.tmpdir(), `subto-scan-${Date.now()}.html`);
577
+ await fs.writeFile(tmpPath, resp.body, 'utf8');
578
+ console.error(chalk.yellow(`Server returned HTML. Saved response to: ${tmpPath}`));
579
+ if (VERBOSE) console.error(chalk.dim('You can open that file in a browser to inspect what the server returned.'));
580
+ resp.body = { status: 'unknown', note: `Server returned HTML (saved to ${tmpPath}). Use --wait or provide the scanId to fetch structured JSON.` };
581
+ } catch (writeErr) {
582
+ resp.body = { status: 'unknown', note: 'Server returned HTML. Use --wait or provide the scanId to fetch structured JSON.' };
583
+ }
461
584
  }
462
585
  }
463
586
 
@@ -479,7 +602,7 @@ async function run(argv) {
479
602
  process.exit(1);
480
603
  }
481
604
 
482
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
605
+ const base = SUBTO_HOST_BASE;
483
606
  const fetchFn = global.fetch;
484
607
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
485
608
 
@@ -496,9 +619,27 @@ async function run(argv) {
496
619
  // Track last server-provided percent to detect stalls
497
620
  let lastServerPercent = null;
498
621
  let lastServerChangeAt = Date.now();
622
+ let promptedSkipExternal = false;
623
+ let skipRequestedAt = null;
624
+ const globalOpts = (program && typeof program.opts === 'function') ? program.opts() : {};
625
+ const SKIP_PROMPT_MS = Number(globalOpts.skipPromptMs || process.env.SUBTO_CLI_SKIP_PROMPT_MS || '15000'); // prompt after 15s stuck at 50%
626
+ const SKIP_COUNTDOWN_MS = Number(globalOpts.skipCountdownMs || process.env.SUBTO_CLI_SKIP_COUNTDOWN_MS || '10000'); // countdown before auto-skip
627
+ // If the scan remains stuck even after the interactive countdown, force auto-skip after this extra window
628
+ const SKIP_FORCE_AFTER_MS = Number(globalOpts.skipForceMs || process.env.SUBTO_CLI_SKIP_FORCE_AFTER_MS || '15000'); // default 15s
629
+ const AUTO_SKIP = (typeof globalOpts.autoSkip !== 'undefined') ? Boolean(globalOpts.autoSkip) : (globalOpts.noAutoSkip ? false : true);
499
630
  let lastPollTs = Date.now();
500
631
  const terminalStates = ['finished', 'completed', 'done', 'complete', 'success', 'succeeded'];
501
632
 
633
+ function markSkipRequested(currentServerPct){
634
+ try{
635
+ skipRequestedAt = Date.now();
636
+ lastServerChangeAt = Date.now();
637
+ // nudge targetPercent forward but avoid jumping to 100 — leave room for analysis
638
+ const base = (typeof currentServerPct === 'number' && currentServerPct > 0) ? currentServerPct : (lastServerPercent || 50);
639
+ targetPercent = Math.max(targetPercent, Math.min(90, Math.round(base + 10)));
640
+ }catch(e){}
641
+ }
642
+
502
643
  // Render a single-line progress: spinner + label + [###-------] NN%
503
644
  let lastRender = '';
504
645
  function computePercentFromData(d){
@@ -661,7 +802,7 @@ async function run(argv) {
661
802
  let data = null;
662
803
  startSpinner();
663
804
  while (true) {
664
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
805
+ const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
665
806
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
666
807
 
667
808
  if (r.status === 429) {
@@ -687,6 +828,174 @@ async function run(argv) {
687
828
  // render progress line
688
829
  renderLine(data);
689
830
 
831
+ // Stall detection: update lastServerPercent timestamp when server percent changes
832
+ try {
833
+ const serverPct = computePercentFromData(data);
834
+ if (typeof serverPct === 'number') {
835
+ if (lastServerPercent === null || serverPct !== lastServerPercent) {
836
+ lastServerPercent = serverPct;
837
+ lastServerChangeAt = Date.now();
838
+ }
839
+ }
840
+ // If stuck at ~50% for a while, prompt user to skip external APIs
841
+ if (!promptedSkipExternal && typeof serverPct === 'number' && serverPct === 50) {
842
+ const stuckMs = Date.now() - lastServerChangeAt;
843
+ if (stuckMs > SKIP_PROMPT_MS) {
844
+ promptedSkipExternal = true;
845
+ var promptedSkipExternalAt = Date.now();
846
+ // If not interactive, behave as before and auto-request skip immediately.
847
+ if (!process.stdin || !process.stdin.isTTY) {
848
+ try {
849
+ stopSpinner();
850
+ if (AUTO_SKIP) {
851
+ console.log(chalk.yellow('\nNo progress detected on external APIs — requesting server to skip external API calls (auto).'));
852
+ const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
853
+ markSkipRequested(lastServerPercent);
854
+ await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
855
+ console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
856
+ } else {
857
+ console.log(chalk.yellow('\nNo progress detected on external APIs. Automatic skip is disabled.'));
858
+ }
859
+ } catch (e) {
860
+ console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
861
+ } finally {
862
+ startSpinner();
863
+ }
864
+ } else {
865
+ // Interactive TTY: show a countdown and allow single-key to skip now or continue waiting.
866
+ stopSpinner();
867
+ try {
868
+ const totalMs = SKIP_COUNTDOWN_MS;
869
+ let remaining = totalMs;
870
+ process.stdout.write('\n');
871
+ 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.`));
872
+
873
+ // Ensure stdin in raw mode so we can capture single key presses
874
+ const stdin = process.stdin;
875
+ stdin.resume();
876
+ if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
877
+
878
+ let resolved = false;
879
+
880
+ const cleanup = () => {
881
+ try {
882
+ if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
883
+ } catch (e) {}
884
+ try { stdin.pause(); } catch (e) {}
885
+ };
886
+
887
+ const onKey = async (buf) => {
888
+ if (resolved) return;
889
+ const ch = String(buf || '').toLowerCase();
890
+ if (ch === '\u0003') {
891
+ // Ctrl+C - restore and exit
892
+ cleanup();
893
+ process.exit(1);
894
+ }
895
+ if (ch === 's') {
896
+ resolved = true;
897
+ stdin.removeListener('data', onKey);
898
+ cleanup();
899
+ // send skip now
900
+ try {
901
+ console.log(chalk.yellow('\nSkip requested by user.'));
902
+ const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
903
+ markSkipRequested(serverPct);
904
+ await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
905
+ console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
906
+ } catch (e) {
907
+ console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
908
+ } finally {
909
+ startSpinner();
910
+ }
911
+ } else if (ch === 'w') {
912
+ // continue waiting
913
+ resolved = true;
914
+ stdin.removeListener('data', onKey);
915
+ cleanup();
916
+ promptedSkipExternal = false;
917
+ lastServerChangeAt = Date.now();
918
+ console.log(chalk.dim('\nContinuing to wait for external API calls...'));
919
+ startSpinner();
920
+ }
921
+ };
922
+
923
+ stdin.on('data', onKey);
924
+
925
+ // countdown interval to update message
926
+ const tick = 1000;
927
+ const intervalId = setInterval(() => {
928
+ remaining -= tick;
929
+ if (resolved) {
930
+ clearInterval(intervalId);
931
+ return;
932
+ }
933
+ if (remaining <= 0) {
934
+ clearInterval(intervalId);
935
+ stdin.removeListener('data', onKey);
936
+ cleanup();
937
+ // timeout reached - auto-skip
938
+ (async () => {
939
+ try {
940
+ if (AUTO_SKIP) {
941
+ console.log(chalk.yellow('\nAuto-skip timeout reached — requesting server to skip external API calls.'));
942
+ const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
943
+ markSkipRequested(lastServerPercent);
944
+ await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
945
+ console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
946
+ } else {
947
+ console.log(chalk.yellow('\nAuto-skip timeout reached but automatic skip is disabled. Continuing to wait.'));
948
+ }
949
+ } catch (e) {
950
+ console.error(chalk.red('Failed to request skip:'), e && e.message ? e.message : e);
951
+ } finally {
952
+ startSpinner();
953
+ }
954
+ })();
955
+ } else {
956
+ try {
957
+ readline.clearLine(process.stdout, 0);
958
+ readline.cursorTo(process.stdout, 0);
959
+ process.stdout.write(chalk.yellow(`Auto-skip in ${Math.ceil(remaining/1000)}s. Press 's' to skip now, 'w' to continue waiting.`));
960
+ } catch (e) {
961
+ // ignore render errors
962
+ }
963
+ }
964
+ }, tick);
965
+
966
+ } catch (e) {
967
+ console.error(chalk.red('Prompt error:'), e && e.message ? e.message : e);
968
+ startSpinner();
969
+ }
970
+ }
971
+ }
972
+ }
973
+ } catch (e) { /* ignore stall-detection errors */ }
974
+
975
+ // If user was prompted but no action taken and overall time exceeds force threshold, auto-skip
976
+ try {
977
+ if (promptedSkipExternal && promptedSkipExternalAt && (Date.now() - promptedSkipExternalAt) > SKIP_FORCE_AFTER_MS) {
978
+ // send skip once and reset flag
979
+ promptedSkipExternal = false;
980
+ try {
981
+ stopSpinner();
982
+ if (AUTO_SKIP) {
983
+ console.log(chalk.yellow('\nScan stuck at 50% for too long — auto-requesting skip of external APIs.'));
984
+ const skipUrl = new URL(`scan/${scanId}/skip-apis`, SUBTO_API_BASE_SLASH).toString();
985
+ markSkipRequested(lastServerPercent);
986
+ await fetchFn(skipUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Type': 'application/json' }, body: '{}' });
987
+ console.log(chalk.yellow('Requested server to skip external API calls for this scan.'));
988
+ } else {
989
+ console.log(chalk.yellow('\nScan stuck at 50% for too long but automatic skip is disabled.'));
990
+ }
991
+ } catch (e) {
992
+ console.error(chalk.red('Failed to auto-request skip:'), e && e.message ? e.message : e);
993
+ } finally {
994
+ startSpinner();
995
+ }
996
+ }
997
+ } catch (e) { /* ignore */ }
998
+
690
999
  // consider terminal if status matches known terminal states OR percent maps to 100
691
1000
  const pctNow = computePercentFromData(data);
692
1001
  const done = (statusStr && terminalStates.includes(statusStr)) || (typeof pctNow === 'number' && pctNow === 100);
@@ -710,15 +1019,6 @@ async function run(argv) {
710
1019
  console.log(chalk.green('Scan finished. Full results:'));
711
1020
  if (opts && opts.json) console.log(JSON.stringify(data, null, 2));
712
1021
  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
1022
  if (opts && opts.chat) await startChatREPL(data);
723
1023
  return;
724
1024
  }
@@ -731,14 +1031,6 @@ async function run(argv) {
731
1031
 
732
1032
  // Default (no wait): pretty summary
733
1033
  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
1034
  if (opts && opts.chat) await startChatREPL(resp.body);
743
1035
  } catch (err) { const msg = err && err.message ? err.message : String(err); console.error(chalk.red('Network error:'), msg); process.exit(1); }
744
1036
  });
@@ -767,10 +1059,10 @@ async function run(argv) {
767
1059
 
768
1060
  // Send to server
769
1061
  try {
770
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
1062
+ const base = SUBTO_HOST_BASE;
771
1063
  const endpoint = new URL('/api/v1/upload', base).toString();
772
1064
  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, expiresInSeconds: 86400 } };
1065
+ const body = { files: samples, meta: { collected: collected.length, totalBytes } };
774
1066
  const r = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}` }, body: JSON.stringify(body) });
775
1067
  if (!r.ok) {
776
1068
  const txt = await r.text().catch(()=>null);
@@ -786,7 +1078,7 @@ async function run(argv) {
786
1078
  const j = await r.json(); console.log(chalk.green('Upload queued:'), j.uploadId, 'scanId:', j.scanId, 'expiresAt:', new Date(j.expiresAt).toString());
787
1079
  if (opts.wait) {
788
1080
  // Poll the scan resource until completed (reuse existing polling behavior)
789
- const statusUrl = new URL(`/api/v1/scan/${j.scanId}`, base).toString();
1081
+ const statusUrl = new URL(`scan/${j.scanId}`, SUBTO_API_BASE_SLASH).toString();
790
1082
  const sleep = ms => new Promise(r=>setTimeout(r,ms));
791
1083
  while (true) {
792
1084
  const s = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
@@ -817,10 +1109,10 @@ async function run(argv) {
817
1109
  }
818
1110
 
819
1111
  if (!scanData && scanId) {
820
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
1112
+ const base = SUBTO_HOST_BASE;
821
1113
  const fetchFn = global.fetch;
822
1114
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
823
- const statusUrl = new URL(`/api/v1/scan/${scanId}`, base).toString();
1115
+ const statusUrl = new URL(`scan/${scanId}`, SUBTO_API_BASE_SLASH).toString();
824
1116
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
825
1117
  if (!r.ok) { throw new Error(`Failed to fetch scan ${scanId}: ${r.status}`); }
826
1118
  scanData = await r.json();
@@ -831,8 +1123,6 @@ async function run(argv) {
831
1123
  } catch (e) { console.error(chalk.red('Error starting chat:'), e && e.message ? e.message : String(e)); process.exit(1); }
832
1124
  });
833
1125
 
834
-
835
-
836
1126
  // Upload project files to AI for analysis. Respects `.subtoignore` and always ignores `.env`.
837
1127
  program
838
1128
  .command('upload [dir]')
@@ -840,41 +1130,14 @@ async function run(argv) {
840
1130
  .option('--max-files <n>', 'Maximum number of files to include', '300')
841
1131
  .option('--max-bytes <n>', 'Maximum total bytes to include', String(5 * 1024 * 1024))
842
1132
  .action(async (dir, opts) => {
1133
+ // Support the legacy convenience `subto upload key` by dispatching
1134
+ // to the interactive OpenRouter key storage flow when caller used
1135
+ // the literal token `key` — avoids ambiguity with directory upload.
1136
+ if (String(dir || '').toLowerCase() === 'key') {
1137
+ await storeOpenRouterKeyInteractive();
1138
+ return;
1139
+ }
843
1140
  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
1141
  const target = dir ? path.resolve(dir) : process.cwd();
879
1142
  const maxFiles = parseInt(opts.maxFiles || opts.maxFiles === 0 ? opts.maxFiles : opts.maxFiles, 10) || parseInt(opts.maxFiles || 300, 10) || 300;
880
1143
  const maxBytes = parseInt(opts.maxBytes || opts.maxBytes === 0 ? opts.maxBytes : opts.maxBytes, 10) || (5 * 1024 * 1024);
@@ -961,37 +1224,11 @@ async function run(argv) {
961
1224
 
962
1225
  const prompt = promptParts.join('\n\n');
963
1226
 
964
- console.log(chalk.dim(`Collected ${collected.length} files, ${Math.round(totalBytes/1024)} KB total.`));
965
- // For local testing, support the SUBTO_LOCAL_AI=1 hook to run the local AI stub instead
966
- if (process.env.SUBTO_LOCAL_AI === '1') {
967
- console.log(chalk.dim('Running local AI stub (dry-run) instead of uploading.'));
968
- const answer = await callOpenAI(prompt);
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
- }
1227
+ console.log(chalk.dim(`Collected ${collected.length} files, ${Math.round(totalBytes/1024)} KB total. Sending summary to AI...`));
1228
+ const answer = await callOpenAI(prompt);
1229
+ console.log(chalk.bold('\nAI Analysis:'));
1230
+ console.log(answer);
1231
+ console.log(chalk.dim('\nNote: sensitive values are not printed; rotate any exposed keys if they were stored in the repo.'));
995
1232
  } catch (e) {
996
1233
  console.error(chalk.red('Upload failed:'), e && e.message ? e.message : String(e));
997
1234
  process.exit(1);
@@ -1000,61 +1237,78 @@ async function run(argv) {
1000
1237
 
1001
1238
  // Store a local OpenRouter key + model for client-side AI calls (kept in ~/.subto)
1002
1239
  program
1003
- .command('upload key')
1240
+ .command('upload key [key]')
1004
1241
  .description('Store a local OpenRouter API key and model for analysis (kept locally only)')
1005
- .action(async () => {
1242
+ .action(async (keyArg) => {
1243
+ await storeOpenRouterKeyInteractive(keyArg);
1244
+ });
1245
+
1246
+ // Push a locally-stored OpenRouter/OpenAI key to a running server's internal endpoint.
1247
+ program
1248
+ .command('server-set-ai-key [key]')
1249
+ .description('Push AI API key to a running server via /internal/set-ai-key')
1250
+ .option('--server <url>', 'Server host base (default: ' + SUBTO_HOST_BASE + ')')
1251
+ .option('--secret <secret>', 'Internal task secret header (or set INTERNAL_TASK_SECRET env var)')
1252
+ .option('--provider <provider>', 'Provider name: openrouter|openai', 'openrouter')
1253
+ .option('--model <model>', 'Model identifier to request (OpenRouter model id)', 'openai/gpt-oss-120b:free')
1254
+ .action(async (keyArg, opts) => {
1006
1255
  try {
1007
- const key = await promptHidden('OpenRouter API key: ');
1008
- if (!key || !key.trim()) { console.log('Aborted.'); return; }
1009
- // Ask for preferred model
1010
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1011
- const model = await new Promise(res => rl.question('Model (e.g. openai/gpt-oss-120b:free): ', a => { rl.close(); res(a && a.trim()); }));
1012
- const chosenModel = model && model.length ? model : 'openai/gpt-oss-120b:free';
1013
-
1014
- // Try to validate model exists (best-effort). If validation fails, ask user to confirm saving.
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 */ }
1256
+ const cfg = await readConfig();
1257
+ let key = keyArg || (cfg && (cfg.openrouterKey || cfg.apiKey || cfg.openaiKey));
1258
+ if (!key) key = await promptHidden('API key to push to server: ');
1259
+ if (!key) { console.log(chalk.yellow('No key provided. Aborting.')); return; }
1260
+ const serverBase = opts.server || SUBTO_HOST_BASE;
1261
+ const secret = opts.secret || process.env.INTERNAL_TASK_SECRET || process.env.X_INTERNAL_TASK_SECRET;
1262
+ if (!secret) {
1263
+ 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
1264
  }
1028
-
1029
- if (!validated) {
1030
- const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
1031
- 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()); }));
1032
- if (answer !== 'y' && answer !== 'yes') { console.log('Aborted.'); return; }
1265
+ const fetchFn = global.fetch;
1266
+ if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1267
+ const endpoint = new URL('internal/set-ai-key', serverBase).toString();
1268
+ const body = { key: String(key).trim(), provider: String(opts.provider || 'openrouter'), model: String(opts.model || 'openai/gpt-oss-120b:free') };
1269
+ const res = await fetchFn(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Task-Secret': secret || '' }, body: JSON.stringify(body) });
1270
+ const text = await res.text();
1271
+ let data = null; try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
1272
+ if (res.ok) {
1273
+ console.log(chalk.green('Server accepted the key. AI should be available immediately.'));
1274
+ if (data) console.log(JSON.stringify(data, null, 2));
1275
+ } else {
1276
+ console.error(chalk.red('Server rejected the request:'), res.status, data || text);
1277
+ process.exit(1);
1033
1278
  }
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); }
1279
+ } catch (e) { console.error(chalk.red('Failed to push key:'), e && e.message ? e.message : String(e)); process.exit(1); }
1041
1280
  });
1042
1281
 
1282
+ // Diagnostic helper: fetch video debug info for a scan id and present a friendly summary
1043
1283
  program
1044
- .command('aichat <scanId>')
1045
- .description('Print and optionally open the web AI chat for a scan')
1046
- .action(async (scanId) => {
1284
+ .command('diag video <scanId>')
1285
+ .description('Retrieve video debug info for <scanId> from the server and summarize')
1286
+ .option('--server <url>', 'Server host base (default: ' + SUBTO_HOST_BASE + ')')
1287
+ .action(async (scanId, opts) => {
1047
1288
  try {
1048
- if (!scanId) { console.error(chalk.red('Please provide a scanId')); process.exit(1); }
1049
- const base = (process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE).replace(/\/$/, '');
1050
- const url = `${base}/aichat/${scanId}`;
1051
- console.log('AI Chat URL:', url);
1052
- try {
1053
- if (process.platform === 'darwin') require('child_process').spawnSync('open', [url]);
1054
- else if (process.platform === 'win32') require('child_process').spawnSync('start', [url], { shell: true });
1055
- else require('child_process').spawnSync('xdg-open', [url]);
1056
- } catch (e) { /* ignore open errors */ }
1057
- } catch (e) { console.error(chalk.red('Error:'), e && e.message ? e.message : String(e)); process.exit(1); }
1289
+ const serverBase = opts.server || SUBTO_HOST_BASE;
1290
+ const fetchFn = global.fetch;
1291
+ if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1292
+ const endpoint = new URL(`video/${scanId}/debug`, serverBase).toString();
1293
+ const res = await fetchFn(endpoint, { headers: { 'Accept': 'application/json' } });
1294
+ if (!res.ok) throw new Error(`Server returned ${res.status}`);
1295
+ const json = await res.json();
1296
+ console.log(chalk.bold('\nVideo diagnostic for:'), scanId);
1297
+ if (json && json.scan) {
1298
+ console.log(' URL:', json.scan.url || '(unknown)');
1299
+ console.log(' Status:', json.scan.status || json.scan.lastPhase || '(unknown)');
1300
+ console.log(' CompletedAt:', json.scan.completedAt ? new Date(json.scan.completedAt).toString() : '(not set)');
1301
+ }
1302
+ if (json && Array.isArray(json.checks)) {
1303
+ console.log(chalk.bold('\nChecked paths:'));
1304
+ for (const c of json.checks) {
1305
+ const ok = c.exists ? chalk.green('FOUND') : chalk.red('MISSING');
1306
+ console.log(` - ${c.path} : ${ok}`);
1307
+ }
1308
+ }
1309
+ console.log('\nSummary:', json && json.found ? chalk.green('Video available') : chalk.red('Video not found'));
1310
+ 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.'));
1311
+ } catch (e) { console.error(chalk.red('Video diagnostic failed:'), e && e.message ? e.message : String(e)); process.exit(1); }
1058
1312
  });
1059
1313
 
1060
1314
  if (!argv || argv.length === 0) { program.help(); return; }
@@ -1073,9 +1327,9 @@ async function run(argv) {
1073
1327
  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
1328
  try {
1075
1329
  if (!scanData) {
1076
- const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE; const fetchFn = global.fetch;
1330
+ const base = SUBTO_HOST_BASE; const fetchFn = global.fetch;
1077
1331
  if (typeof fetchFn !== 'function') throw new Error('Global fetch() is not available in this Node runtime. Use Node 18+');
1078
- const statusUrl = new URL(`/api/v1/scan/${answer}`, base).toString();
1332
+ const statusUrl = new URL(`scan/${answer}`, SUBTO_API_BASE_SLASH).toString();
1079
1333
  const r = await fetchFn(statusUrl, { headers: { 'Authorization': `Bearer ${cfg.apiKey}`, 'Accept': 'application/json' } });
1080
1334
  if (!r.ok) throw new Error(`Failed to fetch scan ${answer}: ${r.status}`);
1081
1335
  scanData = await r.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "7.0.0",
3
+ "version": "8.0.1",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"