subto 8.0.1 → 8.0.3

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": "8.0.1" }
43
+ "client": { "name": "subto-cli", "version": "8.0.3" }
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-8.0.1.tgz
101
+ ./dist/subto-8.0.3.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-8.0.1.tgz
107
+ npm install -g ./dist/subto-8.0.3.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: '8.0.1' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '8.0.3' };
15
15
 
16
16
  function configFilePath() { return CONFIG_PATH; }
17
17
 
@@ -61,6 +61,32 @@ async function promptHidden(prompt) {
61
61
  });
62
62
  }
63
63
 
64
+ function isByteStringSafe(s){
65
+ if (typeof s !== 'string') return false;
66
+ for (let i = 0; i < s.length; i++) { if (s.charCodeAt(i) > 255) return false; }
67
+ return true;
68
+ }
69
+
70
+ function normalizeApiKey(s){
71
+ if (typeof s !== 'string') return s;
72
+ let out = s.normalize('NFKC');
73
+ out = out.replace(/[\u200B\uFEFF]/g, '');
74
+ out = out.replace(/\u00A0/g, ' ');
75
+ out = out.replace(/[\u2018\u2019]/g, "'");
76
+ out = out.replace(/[\u201C\u201D]/g, '"');
77
+ out = out.replace(/\u2026/g, '...');
78
+ out = out.replace(/[\u22C5\u00B7]/g, '.');
79
+ out = out.replace(/\u2022/g, '*');
80
+ return out.trim();
81
+ }
82
+
83
+ function askYesNo(promptText){
84
+ return new Promise(res => {
85
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
86
+ rl.question(promptText + ' ', ans => { rl.close(); res(String(ans||'').trim().toLowerCase().startsWith('y')); });
87
+ });
88
+ }
89
+
64
90
  function validateUrl(input) {
65
91
  try { const u = new URL(input); if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; return true; } catch (err) { return false; }
66
92
  }
@@ -69,6 +95,10 @@ async function postScan(url, apiKey) {
69
95
  const base = process.env.SUBTO_API_BASE_URL || DEFAULT_API_BASE;
70
96
  const endpoint = new URL('/api/v1/scan', base).toString();
71
97
  const body = { url, source: 'cli', client: CLIENT_META };
98
+ // Ensure the provided API key is safe for use in HTTP headers
99
+ if (!isByteStringSafe(String(apiKey || ''))) {
100
+ throw new Error('API key contains unsupported characters (non-Latin-1). Re-run `subto login` and paste a plain ASCII API key.');
101
+ }
72
102
  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
103
  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
104
  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,10 +125,17 @@ async function run(argv) {
95
125
 
96
126
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
97
127
  try {
98
- const apiKey = await promptHidden('API key: ');
128
+ let apiKey = await promptHidden('API key: ');
99
129
  if (!apiKey || !apiKey.trim()) throw new Error('No API key entered. Aborting.');
100
130
  if (apiKey.length < 10) throw new Error('API key looks too short.');
101
- await writeConfig({ apiKey: apiKey.trim() });
131
+ const normalized = normalizeApiKey(apiKey);
132
+ if (normalized !== apiKey) {
133
+ console.log(chalk.yellow('Notice: API key contained invisible or fancy characters — it will be sanitized before saving.'));
134
+ const ok = await askYesNo('Save sanitized key? (Y/n)');
135
+ if (!ok) { console.log(chalk.yellow('Login aborted.')); process.exit(1); }
136
+ }
137
+ if (!isByteStringSafe(normalized)) throw new Error('API key contains unsupported characters (non-Latin-1). Provide a plain ASCII key.');
138
+ await writeConfig({ apiKey: normalized });
102
139
  console.log(chalk.green('API key saved to'), chalk.cyan(configFilePath()));
103
140
  } catch (err) { if (err && err.message === 'Aborted') { console.log('\n' + chalk.yellow('Login aborted.')); process.exit(1); } throw err; }
104
141
  });
package/index.js CHANGED
@@ -11,7 +11,7 @@ const chalk = (_chalk && _chalk.default) ? _chalk.default : _chalk;
11
11
  const CONFIG_DIR = path.join(os.homedir(), '.subto');
12
12
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
13
13
  const DEFAULT_API_BASE = 'https://subto.one/api/v1';
14
- const CLIENT_META = { name: 'subto-cli', version: '8.0.1' };
14
+ const CLIENT_META = { name: 'subto-cli', version: '8.0.3' };
15
15
  const cp = require('child_process');
16
16
 
17
17
  // Normalize SUBTO API base so callers can set either
@@ -90,6 +90,33 @@ async function writeConfig(obj) {
90
90
  await fs.rename(tmp, configFilePath());
91
91
  }
92
92
 
93
+ // Helpers: normalization, header-safety check, and simple yes/no prompt
94
+ function isByteStringSafe(s){
95
+ if (typeof s !== 'string') return false;
96
+ for (let i = 0; i < s.length; i++) { if (s.charCodeAt(i) > 255) return false; }
97
+ return true;
98
+ }
99
+
100
+ function normalizeApiKey(s){
101
+ if (typeof s !== 'string') return s;
102
+ let out = s.normalize('NFKC');
103
+ out = out.replace(/[\u200B\uFEFF]/g, '');
104
+ out = out.replace(/\u00A0/g, ' ');
105
+ out = out.replace(/[\u2018\u2019]/g, "'");
106
+ out = out.replace(/[\u201C\u201D]/g, '"');
107
+ out = out.replace(/\u2026/g, '...');
108
+ out = out.replace(/[\u22C5\u00B7]/g, '.');
109
+ out = out.replace(/\u2022/g, '*');
110
+ return out.trim();
111
+ }
112
+
113
+ function askYesNo(promptText){
114
+ return new Promise(res => {
115
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
116
+ rl.question(promptText + ' ', ans => { rl.close(); res(String(ans||'').trim().toLowerCase().startsWith('y')); });
117
+ });
118
+ }
119
+
93
120
  // Interactive helper to store OpenRouter key + model into ~/.subto/config.json
94
121
  async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
95
122
  try {
@@ -98,6 +125,12 @@ async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
98
125
  key = await promptHidden('OpenRouter API key: ');
99
126
  if (!key || !key.trim()) { console.log('Aborted.'); return; }
100
127
  }
128
+ const normalized = normalizeApiKey(key);
129
+ if (normalized !== key) {
130
+ console.log(chalk.yellow('Notice: OpenRouter key contained invisible or fancy characters — it will be sanitized before saving.'));
131
+ const ok = await askYesNo('Save sanitized OpenRouter key? (Y/n)');
132
+ if (!ok) { console.log(chalk.yellow('Aborted.')); return; }
133
+ }
101
134
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
102
135
  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
136
  const chosenModel = model && model.length ? model : 'openai/gpt-oss-120b:free';
@@ -124,7 +157,7 @@ async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
124
157
  }
125
158
 
126
159
  const cfg = await readConfig() || {};
127
- cfg.openrouterKey = String(key).trim();
160
+ cfg.openrouterKey = String(normalized).trim();
128
161
  cfg.openrouterModel = chosenModel;
129
162
  await writeConfig(cfg);
130
163
  console.log(chalk.green('OpenRouter key and model saved to'), chalk.cyan(configFilePath()));
@@ -132,13 +165,7 @@ async function storeOpenRouterKeyInteractive(keyArg, modelArg) {
132
165
  }
133
166
 
134
167
  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
- }
168
+
142
169
  if (!process.stdin.isTTY) throw new Error('Interactive prompt required');
143
170
  return new Promise((resolve, reject) => {
144
171
  const stdin = process.stdin;
@@ -492,7 +519,7 @@ async function startChatREPL(scanData){
492
519
 
493
520
  async function run(argv) {
494
521
  const program = new Command();
495
- program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.1');
522
+ program.name('subto').description('Subto CLI — wrapper around Subto.One API').version(CLIENT_META.version || '8.0.3');
496
523
  program.option('-v, --verbose', 'Show verbose HTTP logs');
497
524
  program.option('--chat', 'Start local AI assistant (no command required)');
498
525
  program.option('--no-auto-skip', 'Disable automatic skipping of external APIs when scans appear stuck');
@@ -502,10 +529,17 @@ async function run(argv) {
502
529
 
503
530
  program.command('login').description('Store your API key in ~/.subto/config.json').action(async () => {
504
531
  try {
505
- const apiKey = await promptHidden('API key: ');
532
+ let apiKey = await promptHidden('API key: ');
506
533
  if (!apiKey || !apiKey.trim()) throw new Error('No API key entered. Aborting.');
507
534
  if (apiKey.length < 10) throw new Error('API key looks too short.');
508
- await writeConfig({ apiKey: apiKey.trim() });
535
+ const normalized = normalizeApiKey(apiKey);
536
+ if (normalized !== apiKey) {
537
+ console.log(chalk.yellow('Notice: API key contained invisible or fancy characters — it will be sanitized before saving.'));
538
+ const ok = await askYesNo('Save sanitized key? (Y/n)');
539
+ if (!ok) { console.log(chalk.yellow('Login aborted.')); process.exit(1); }
540
+ }
541
+ if (!isByteStringSafe(normalized)) throw new Error('API key contains unsupported characters (non-Latin-1). Provide a plain ASCII key.');
542
+ await writeConfig({ apiKey: normalized });
509
543
  console.log(chalk.green('API key saved to'), chalk.cyan(configFilePath()));
510
544
  } catch (err) { if (err && err.message === 'Aborted') { console.log('\n' + chalk.yellow('Login aborted.')); process.exit(1); } throw err; }
511
545
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subto",
3
- "version": "8.0.1",
3
+ "version": "8.0.3",
4
4
  "description": "Subto CLI — thin wrapper around the Subto.One API",
5
5
  "bin": {
6
6
  "subto": "bin/subto.js"