saros-proxy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/dist/cli/opencode-config.d.ts +28 -0
- package/dist/cli/opencode-config.js +126 -0
- package/dist/cli/opencode-config.js.map +1 -0
- package/dist/cli/setup.d.ts +29 -0
- package/dist/cli/setup.js +669 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/ui.d.ts +27 -0
- package/dist/cli/ui.js +116 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +341 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +25 -0
- package/dist/constants.js +162 -0
- package/dist/constants.js.map +1 -0
- package/dist/dashboard-parser.d.ts +47 -0
- package/dist/dashboard-parser.js +88 -0
- package/dist/dashboard-parser.js.map +1 -0
- package/dist/firefox-cookies.d.ts +42 -0
- package/dist/firefox-cookies.js +170 -0
- package/dist/firefox-cookies.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/key-encryption.d.ts +21 -0
- package/dist/key-encryption.js +59 -0
- package/dist/key-encryption.js.map +1 -0
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +35 -0
- package/dist/logger.js.map +1 -0
- package/dist/proxy-logic.d.ts +108 -0
- package/dist/proxy-logic.js +353 -0
- package/dist/proxy-logic.js.map +1 -0
- package/dist/proxy.d.ts +35 -0
- package/dist/proxy.js +551 -0
- package/dist/proxy.js.map +1 -0
- package/dist/scraper.d.ts +70 -0
- package/dist/scraper.js +239 -0
- package/dist/scraper.js.map +1 -0
- package/dist/validation.d.ts +34 -0
- package/dist/validation.js +64 -0
- package/dist/validation.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* setup.ts — Interactive CLI setup wizard for Saros.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx tsx src/cli/setup.ts
|
|
7
|
+
* npm run setup
|
|
8
|
+
*
|
|
9
|
+
* Prompts for configuration values, writes config.yaml, and runs a quick
|
|
10
|
+
* smoke test to verify the proxy starts and responds to health checks.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
14
|
+
import { spawn, exec, execFileSync } from 'node:child_process';
|
|
15
|
+
import { resolve as pathResolve, join as pathJoin, dirname } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { encryptKey } from '../key-encryption.js';
|
|
19
|
+
import { extractFirefoxAuthCookie, extractFirefoxWorkspaceIds } from '../firefox-cookies.js';
|
|
20
|
+
import { scrapeDashboard } from '../scraper.js';
|
|
21
|
+
import { getDefaultOpencodeConfigPath, updateOpencodeConfig, generateManualConfigSnippet, } from './opencode-config.js';
|
|
22
|
+
import { DEFAULT_PORT, DEFAULT_HOST, DEFAULT_UPSTREAM_URL, DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SCRAPE_INTERVAL_MS, DEFAULT_USAGE_THRESHOLD, } from '../constants.js';
|
|
23
|
+
import { isValidPort, isValidApiKey, isValidHttpsUrl, isValidPositiveInt } from '../validation.js';
|
|
24
|
+
import { getDefaultConfigPath } from '../config.js';
|
|
25
|
+
import * as ui from './ui.js';
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Constants
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const PACKAGE_ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Environment variable persistence
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/** Try to save an environment variable persistently so the user doesn't
|
|
34
|
+
* have to export it manually each time.
|
|
35
|
+
*
|
|
36
|
+
* - Windows: `setx <name> <value>` (user-level, persists across reboots)
|
|
37
|
+
* - Linux/macOS: append `export <name>=<value>` to ~/.bashrc or ~/.zshrc
|
|
38
|
+
*
|
|
39
|
+
* Returns true on success, false on failure (non-fatal).
|
|
40
|
+
*/
|
|
41
|
+
function saveEnvVar(name, value) {
|
|
42
|
+
try {
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
// Use execFileSync to avoid shell injection (setx PATH "foo & calc & ")
|
|
45
|
+
execFileSync('setx', [name, value], { windowsHide: true, timeout: 5000 });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
// Linux/macOS — append to shell profile
|
|
49
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
50
|
+
if (!home)
|
|
51
|
+
return false;
|
|
52
|
+
const profilePaths = [
|
|
53
|
+
pathJoin(home, '.zshrc'),
|
|
54
|
+
pathJoin(home, '.bashrc'),
|
|
55
|
+
pathJoin(home, '.bash_profile'),
|
|
56
|
+
pathJoin(home, '.profile'),
|
|
57
|
+
];
|
|
58
|
+
const existing = profilePaths.find((p) => existsSync(p));
|
|
59
|
+
const targetPath = existing || profilePaths[0];
|
|
60
|
+
// Escape single quotes for shell profile: 'foo' -> '"'"' (breaks out of single quotes, inserts literal ', resumes)
|
|
61
|
+
const escaped = value.replaceAll("'", String.raw `'\''`);
|
|
62
|
+
const line = `\nexport ${name}='${escaped}'\n`;
|
|
63
|
+
// Avoid adding duplicates — anchored to line start
|
|
64
|
+
const content = existsSync(targetPath) ? readFileSync(targetPath, 'utf-8') : '';
|
|
65
|
+
if (new RegExp(`^export ${name}=`, 'm').test(content))
|
|
66
|
+
return false;
|
|
67
|
+
writeFileSync(targetPath, content + line, 'utf-8');
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Scraping accounts setup
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
function ordinal(n) {
|
|
78
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
79
|
+
const v = n % 100;
|
|
80
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0] || 'th');
|
|
81
|
+
}
|
|
82
|
+
export function generateYaml(cfg, encryptionEnabled = false) {
|
|
83
|
+
const config = {
|
|
84
|
+
port: cfg.port,
|
|
85
|
+
host: DEFAULT_HOST,
|
|
86
|
+
upstreamBaseUrl: cfg.upstreamBaseUrl,
|
|
87
|
+
circuitBreakerThreshold: 3,
|
|
88
|
+
circuitBreakerCooldownMs: DEFAULT_CIRCUIT_BREAKER_COOLDOWN_MS,
|
|
89
|
+
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
90
|
+
allowedOrigins: ['http://localhost:*', 'http://127.0.0.1:*'],
|
|
91
|
+
keys: cfg.keys,
|
|
92
|
+
};
|
|
93
|
+
if (cfg.scrapingAccounts && cfg.scrapingAccounts.length > 0) {
|
|
94
|
+
config.scraping = {
|
|
95
|
+
enabled: true,
|
|
96
|
+
intervalMs: cfg.scrapingIntervalMs ?? DEFAULT_SCRAPE_INTERVAL_MS,
|
|
97
|
+
usageThreshold: cfg.scrapingThreshold ?? DEFAULT_USAGE_THRESHOLD,
|
|
98
|
+
accounts: cfg.scrapingAccounts,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
let yaml = stringifyYaml(config);
|
|
102
|
+
if (encryptionEnabled) {
|
|
103
|
+
yaml = `# [!] API keys are encrypted with AES-256-GCM\n# Set OPENCODE_GO_ENCRYPTION_KEY environment variable to start the proxy\n\n${yaml}`;
|
|
104
|
+
}
|
|
105
|
+
return yaml;
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Smoke test
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
/** Kill a process and all its children (cross-platform, non-blocking). */
|
|
111
|
+
function killProcessTree(child) {
|
|
112
|
+
if (!child.pid)
|
|
113
|
+
return;
|
|
114
|
+
if (process.platform === 'win32') {
|
|
115
|
+
exec(`taskkill /T /F /PID ${child.pid}`, { windowsHide: true }, () => { });
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
child.kill('SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export async function testProxy(port, timeoutMs = 20_000, cwd = PACKAGE_ROOT, encryptionKey, configPath) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
// Resolve the entry point relative to the package root
|
|
124
|
+
const entryPoint = pathResolve(PACKAGE_ROOT, 'src', 'index.ts');
|
|
125
|
+
// Use single-command form with shell:true to avoid DEP0190 deprecation
|
|
126
|
+
// (Node.js deprecates passing both shell:true and separate args)
|
|
127
|
+
const configFlag = configPath ? ` --config "${configPath}"` : '';
|
|
128
|
+
const cmd = `npx tsx "${entryPoint}" --port ${port}${configFlag}`;
|
|
129
|
+
const child = spawn(cmd, [], {
|
|
130
|
+
cwd,
|
|
131
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
132
|
+
shell: true,
|
|
133
|
+
detached: false, // killProcessTree() handles cleanup on both platforms
|
|
134
|
+
env: {
|
|
135
|
+
...process.env,
|
|
136
|
+
LOG_LEVEL: 'silent',
|
|
137
|
+
NODE_ENV: 'production',
|
|
138
|
+
...(encryptionKey ? { OPENCODE_GO_ENCRYPTION_KEY: encryptionKey } : {}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
let started = false;
|
|
142
|
+
let timedOut = false;
|
|
143
|
+
let resolved = false;
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
timedOut = true;
|
|
146
|
+
killProcessTree(child);
|
|
147
|
+
if (!resolved) {
|
|
148
|
+
resolved = true;
|
|
149
|
+
reject(new Error('Proxy did not start within the allotted time.'));
|
|
150
|
+
}
|
|
151
|
+
}, timeoutMs);
|
|
152
|
+
// Collect stderr for error reporting
|
|
153
|
+
let stderrBuf = '';
|
|
154
|
+
if (child.stderr) {
|
|
155
|
+
child.stderr.on('data', (chunk) => {
|
|
156
|
+
stderrBuf += chunk.toString();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Poll the health endpoint until we get a response
|
|
160
|
+
const pollId = setInterval(async () => {
|
|
161
|
+
if (timedOut) {
|
|
162
|
+
clearInterval(pollId);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
167
|
+
if (res.ok) {
|
|
168
|
+
// Parse JSON first — don't mark as started until we confirm it's our proxy
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
started = true;
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
clearInterval(pollId);
|
|
173
|
+
ui.success(`Health check returned status "${data.status}"`);
|
|
174
|
+
ui.success(`${data.enabledCount}/${data.keyCount} API key(s) enabled`);
|
|
175
|
+
if (data.disabledCount > 0) {
|
|
176
|
+
ui.warn(`${data.disabledCount} key(s) disabled (check API key validity)`);
|
|
177
|
+
}
|
|
178
|
+
// Kill the child and wait for it to exit before resolving
|
|
179
|
+
killProcessTree(child);
|
|
180
|
+
child.once('exit', () => {
|
|
181
|
+
if (!resolved) {
|
|
182
|
+
resolved = true;
|
|
183
|
+
resolve();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Fallback: if exit doesn't fire within 2s, resolve anyway
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
if (!resolved) {
|
|
189
|
+
resolved = true;
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
}, 2000);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Server not ready yet — keep polling
|
|
197
|
+
}
|
|
198
|
+
}, 400);
|
|
199
|
+
child.on('error', (err) => {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
clearInterval(pollId);
|
|
202
|
+
if (!started && !resolved) {
|
|
203
|
+
resolved = true;
|
|
204
|
+
reject(new Error(`Failed to start proxy process: ${err.message}`));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
child.on('exit', (code, signal) => {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
clearInterval(pollId);
|
|
210
|
+
// If child exits before we detected it as started, that's a failure
|
|
211
|
+
// (on Windows with shell:true, exit code may be 0 even when proxy failed)
|
|
212
|
+
if (!started && !resolved) {
|
|
213
|
+
resolved = true;
|
|
214
|
+
let hint;
|
|
215
|
+
if (stderrBuf.includes('EADDRINUSE')) {
|
|
216
|
+
hint = `Port ${port} is already in use. Choose a different port.`;
|
|
217
|
+
}
|
|
218
|
+
else if (stderrBuf.includes('No valid API keys')) {
|
|
219
|
+
hint = 'No valid API keys found. Ensure keys start with "sk-" and are at least 20 characters.';
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const stderrSuffix = stderrBuf ? `\n Stderr: ${stderrBuf.slice(0, 500)}` : '';
|
|
223
|
+
hint = `Proxy exited with code ${code}${stderrSuffix}`;
|
|
224
|
+
}
|
|
225
|
+
reject(new Error(hint));
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Backup existing config
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
export function backupExistingConfig(configDir = dirname(getDefaultConfigPath())) {
|
|
234
|
+
const configPath = pathResolve(configDir, 'config.yaml');
|
|
235
|
+
if (!existsSync(configPath))
|
|
236
|
+
return false;
|
|
237
|
+
const backupPath = pathResolve(configDir, 'config.yaml.backup');
|
|
238
|
+
try {
|
|
239
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
240
|
+
writeFileSync(backupPath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
241
|
+
ui.info('Existing config.yaml backed up to config.yaml.backup');
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
ui.warn(`Could not create backup: ${err}`);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function formatUsageLine(usage) {
|
|
250
|
+
if (!usage)
|
|
251
|
+
return '';
|
|
252
|
+
return [
|
|
253
|
+
usage.rolling == null ? '' : `Rolling: ${usage.rolling}%`,
|
|
254
|
+
usage.weekly == null ? '' : `Weekly: ${usage.weekly}%`,
|
|
255
|
+
usage.monthly == null ? '' : `Monthly: ${usage.monthly}%`,
|
|
256
|
+
].filter(Boolean).join(' ');
|
|
257
|
+
}
|
|
258
|
+
async function promptServerConfig() {
|
|
259
|
+
const portStr = ui.assertNotCancelled(await ui.text({
|
|
260
|
+
message: 'Proxy port',
|
|
261
|
+
placeholder: String(DEFAULT_PORT),
|
|
262
|
+
validate: (v) => {
|
|
263
|
+
if (v && !isValidPort(v))
|
|
264
|
+
return 'Port must be a number between 1 and 65535.';
|
|
265
|
+
},
|
|
266
|
+
}));
|
|
267
|
+
const port = Number.parseInt(portStr || String(DEFAULT_PORT), 10);
|
|
268
|
+
const upstreamBaseUrl = ui.assertNotCancelled(await ui.text({
|
|
269
|
+
message: 'Upstream API base URL',
|
|
270
|
+
placeholder: DEFAULT_UPSTREAM_URL,
|
|
271
|
+
validate: (v) => {
|
|
272
|
+
if (v && !isValidHttpsUrl(v))
|
|
273
|
+
return 'Must be a valid HTTPS URL (e.g. https://opencode.ai).';
|
|
274
|
+
},
|
|
275
|
+
})) || DEFAULT_UPSTREAM_URL;
|
|
276
|
+
return { port, upstreamBaseUrl };
|
|
277
|
+
}
|
|
278
|
+
async function promptAccountCount() {
|
|
279
|
+
const numAccountsStr = ui.assertNotCancelled(await ui.text({
|
|
280
|
+
message: 'Number of accounts',
|
|
281
|
+
placeholder: '2',
|
|
282
|
+
validate: (v) => {
|
|
283
|
+
if (v && !isValidPositiveInt(v, 1, 100))
|
|
284
|
+
return 'Must be a number between 1 and 100.';
|
|
285
|
+
},
|
|
286
|
+
}));
|
|
287
|
+
return Number.parseInt(numAccountsStr || '2', 10);
|
|
288
|
+
}
|
|
289
|
+
async function promptEncryption() {
|
|
290
|
+
const encryptKeys = ui.assertNotCancelled(await ui.confirm({
|
|
291
|
+
message: 'Encrypt API keys? (recommended for security)',
|
|
292
|
+
initialValue: true,
|
|
293
|
+
}));
|
|
294
|
+
if (!encryptKeys) {
|
|
295
|
+
return { encryptionKey: undefined, envSaved: false };
|
|
296
|
+
}
|
|
297
|
+
const encryptionKey = ui.assertNotCancelled(await ui.password({
|
|
298
|
+
message: 'Master encryption key (min 16 characters)',
|
|
299
|
+
validate: (v) => {
|
|
300
|
+
if (!v || v.length < 16)
|
|
301
|
+
return 'Encryption key must be at least 16 characters long';
|
|
302
|
+
},
|
|
303
|
+
}));
|
|
304
|
+
ui.success('All API keys will be encrypted with AES-256-GCM');
|
|
305
|
+
const saveEnv = ui.assertNotCancelled(await ui.confirm({
|
|
306
|
+
message: 'Save OPENCODE_GO_ENCRYPTION_KEY to your environment?',
|
|
307
|
+
initialValue: true,
|
|
308
|
+
}));
|
|
309
|
+
let envSaved = false;
|
|
310
|
+
if (saveEnv) {
|
|
311
|
+
if (saveEnvVar('OPENCODE_GO_ENCRYPTION_KEY', encryptionKey)) {
|
|
312
|
+
ui.success('OPENCODE_GO_ENCRYPTION_KEY saved');
|
|
313
|
+
envSaved = true;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
ui.warn('Could not save to environment. Set it manually:');
|
|
317
|
+
ui.info(` export OPENCODE_GO_ENCRYPTION_KEY="${encryptionKey}"`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { encryptionKey, envSaved };
|
|
321
|
+
}
|
|
322
|
+
async function promptScrapingConfig() {
|
|
323
|
+
const wantScraping = ui.assertNotCancelled(await ui.confirm({
|
|
324
|
+
message: 'Enable usage-based account switching?',
|
|
325
|
+
initialValue: true,
|
|
326
|
+
}));
|
|
327
|
+
if (!wantScraping)
|
|
328
|
+
return null;
|
|
329
|
+
ui.step('Usage-Based Switching Config');
|
|
330
|
+
const thresholdStr = ui.assertNotCancelled(await ui.text({
|
|
331
|
+
message: 'Usage threshold (switch when usage exceeds this %)',
|
|
332
|
+
placeholder: '70',
|
|
333
|
+
validate: (v) => {
|
|
334
|
+
if (v && (!/^\d+$/.test(v) || Number.parseInt(v) < 1 || Number.parseInt(v) > 100))
|
|
335
|
+
return 'Enter a number between 1 and 100.';
|
|
336
|
+
},
|
|
337
|
+
}));
|
|
338
|
+
const threshold = Number.parseInt(thresholdStr || '70', 10);
|
|
339
|
+
const intervalStr = ui.assertNotCancelled(await ui.text({
|
|
340
|
+
message: 'Scrape interval in seconds',
|
|
341
|
+
placeholder: '90',
|
|
342
|
+
validate: (v) => {
|
|
343
|
+
if (v && (!/^\d+$/.test(v) || Number.parseInt(v) < 10))
|
|
344
|
+
return 'Enter a number of seconds (minimum 10).';
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
const intervalMs = Number.parseInt(intervalStr || '90', 10) * 1000;
|
|
348
|
+
return { accounts: [], threshold, intervalMs };
|
|
349
|
+
}
|
|
350
|
+
async function collectAccountCredentials(opts) {
|
|
351
|
+
const keys = [];
|
|
352
|
+
const scrapingAccounts = [];
|
|
353
|
+
const seenCookies = new Set();
|
|
354
|
+
const seenWorkspaceIds = new Set();
|
|
355
|
+
for (let i = 0; i < opts.numAccounts; i++) {
|
|
356
|
+
ui.step(`Account ${i + 1} of ${opts.numAccounts}`);
|
|
357
|
+
const label = await promptAccountLabel(i, opts.numAccounts);
|
|
358
|
+
const key = await promptAccountApiKey();
|
|
359
|
+
const finalKey = opts.encryptionKey ? encryptKey(key, opts.encryptionKey) : key;
|
|
360
|
+
keys.push({ label, key: finalKey });
|
|
361
|
+
ui.success(`"${label}" API key configured`);
|
|
362
|
+
if (opts.wantScraping) {
|
|
363
|
+
const scraping = await collectScrapingCredentials(i, opts.numAccounts, opts.encryptionKey, seenCookies, seenWorkspaceIds);
|
|
364
|
+
if (scraping)
|
|
365
|
+
scrapingAccounts.push(scraping);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
keys,
|
|
370
|
+
scrapingAccounts: opts.wantScraping && scrapingAccounts.length > 0 ? scrapingAccounts : undefined,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function promptAccountLabel(index, total) {
|
|
374
|
+
const placeholder = `account${index + 1}`;
|
|
375
|
+
const label = ui.assertNotCancelled(await ui.text({
|
|
376
|
+
message: 'Label for this account',
|
|
377
|
+
placeholder,
|
|
378
|
+
validate: (v) => {
|
|
379
|
+
if (v === '')
|
|
380
|
+
return 'This field is required.';
|
|
381
|
+
},
|
|
382
|
+
}));
|
|
383
|
+
return label || placeholder;
|
|
384
|
+
}
|
|
385
|
+
async function promptAccountApiKey() {
|
|
386
|
+
return ui.assertNotCancelled(await ui.password({
|
|
387
|
+
message: 'API key',
|
|
388
|
+
validate: (v) => {
|
|
389
|
+
if (!v || !isValidApiKey(v))
|
|
390
|
+
return 'API key must start with "sk-" and be at least 20 characters long.';
|
|
391
|
+
},
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
async function collectScrapingCredentials(index, total, encryptionKey, seenCookies, seenWorkspaceIds) {
|
|
395
|
+
ui.info(`Log into ${index === 0 ? 'first' : ordinal(index + 1)} account in Firefox, then press Enter`);
|
|
396
|
+
ui.assertNotCancelled(await ui.text({
|
|
397
|
+
message: 'Press Enter when ready (log into Firefox first)',
|
|
398
|
+
placeholder: 'Press Enter to continue',
|
|
399
|
+
validate: () => undefined,
|
|
400
|
+
}));
|
|
401
|
+
const authCookie = await extractOrPromptAuthCookie(seenCookies);
|
|
402
|
+
const workspaceId = await selectOrPromptWorkspace(seenWorkspaceIds);
|
|
403
|
+
const validatedId = await validateAndRetry(workspaceId, authCookie);
|
|
404
|
+
if (!validatedId)
|
|
405
|
+
return null;
|
|
406
|
+
seenCookies.add(authCookie);
|
|
407
|
+
seenWorkspaceIds.add(validatedId);
|
|
408
|
+
return {
|
|
409
|
+
workspaceId: validatedId,
|
|
410
|
+
authCookie: encryptionKey ? encryptKey(authCookie, encryptionKey) : authCookie,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
async function extractOrPromptAuthCookie(seenCookies) {
|
|
414
|
+
const extractSpinner = ui.spinner();
|
|
415
|
+
extractSpinner.start('Extracting auth cookie from Firefox...');
|
|
416
|
+
const cookieResult = extractFirefoxAuthCookie();
|
|
417
|
+
if (cookieResult.cookie) {
|
|
418
|
+
extractSpinner.stop('Extracted');
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
extractSpinner.error('No cookie found');
|
|
422
|
+
}
|
|
423
|
+
let authCookie = cookieResult.cookie ?? null;
|
|
424
|
+
if (authCookie) {
|
|
425
|
+
ui.success('Auth cookie extracted from Firefox');
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
ui.warn(`Firefox extraction: ${cookieResult.error || 'No cookie found'}`);
|
|
429
|
+
authCookie = ui.assertNotCancelled(await ui.text({
|
|
430
|
+
message: 'Auth cookie value (from Firefox DevTools)',
|
|
431
|
+
validate: (v) => {
|
|
432
|
+
if (!v || v.length < 10)
|
|
433
|
+
return 'Cookie value must be at least 10 characters.';
|
|
434
|
+
},
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
if (seenCookies.has(authCookie)) {
|
|
438
|
+
ui.warn('This cookie matches a previous account. Did you log out?');
|
|
439
|
+
const retry = ui.assertNotCancelled(await ui.confirm({
|
|
440
|
+
message: 'Try again with a different account?',
|
|
441
|
+
initialValue: true,
|
|
442
|
+
}));
|
|
443
|
+
if (retry) {
|
|
444
|
+
authCookie = ui.assertNotCancelled(await ui.text({
|
|
445
|
+
message: 'Auth cookie value (from Firefox DevTools)',
|
|
446
|
+
validate: (v) => {
|
|
447
|
+
if (!v || v.length < 10)
|
|
448
|
+
return 'Cookie value must be at least 10 characters.';
|
|
449
|
+
},
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return authCookie;
|
|
454
|
+
}
|
|
455
|
+
async function selectOrPromptWorkspace(seenWorkspaceIds) {
|
|
456
|
+
const workspaceIds = extractFirefoxWorkspaceIds();
|
|
457
|
+
let workspaceId;
|
|
458
|
+
if (workspaceIds.length === 0) {
|
|
459
|
+
workspaceId = ui.assertNotCancelled(await ui.text({
|
|
460
|
+
message: 'Workspace ID (wrk_...)',
|
|
461
|
+
validate: (v) => {
|
|
462
|
+
if (!v || !/^wrk_[A-Za-z0-9]+$/.test(v))
|
|
463
|
+
return 'Must match format: wrk_... (e.g. wrk_abc123).';
|
|
464
|
+
},
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
467
|
+
else if (workspaceIds.length === 1) {
|
|
468
|
+
workspaceId = workspaceIds[0];
|
|
469
|
+
ui.success(`Found workspace: ${workspaceId}`);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
ui.listWorkspaces(workspaceIds);
|
|
473
|
+
const choiceStr = ui.assertNotCancelled(await ui.text({
|
|
474
|
+
message: 'Enter the number for this account',
|
|
475
|
+
placeholder: '1',
|
|
476
|
+
validate: (v) => {
|
|
477
|
+
if (v && (!/^\d+$/.test(v) || Number.parseInt(v) < 1 || Number.parseInt(v) > workspaceIds.length))
|
|
478
|
+
return `Enter a number between 1 and ${workspaceIds.length}.`;
|
|
479
|
+
},
|
|
480
|
+
}));
|
|
481
|
+
workspaceId = workspaceIds[Number.parseInt(choiceStr || '1', 10) - 1];
|
|
482
|
+
}
|
|
483
|
+
if (seenWorkspaceIds.has(workspaceId)) {
|
|
484
|
+
ui.warn('This workspace ID belongs to another account.');
|
|
485
|
+
const retry = ui.assertNotCancelled(await ui.confirm({
|
|
486
|
+
message: 'Enter a different workspace ID?',
|
|
487
|
+
initialValue: true,
|
|
488
|
+
}));
|
|
489
|
+
if (retry) {
|
|
490
|
+
workspaceId = ui.assertNotCancelled(await ui.text({
|
|
491
|
+
message: 'Workspace ID (wrk_...)',
|
|
492
|
+
validate: (v) => {
|
|
493
|
+
if (!v || !/^wrk_[A-Za-z0-9]+$/.test(v))
|
|
494
|
+
return 'Must match format: wrk_... (e.g. wrk_abc123).';
|
|
495
|
+
},
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return workspaceId;
|
|
500
|
+
}
|
|
501
|
+
async function validateAndRetry(workspaceId, authCookie) {
|
|
502
|
+
const vSpinner = ui.spinner();
|
|
503
|
+
vSpinner.start('Validating cookie against opencode.ai...');
|
|
504
|
+
const scrapeResult = await scrapeDashboard(workspaceId, authCookie);
|
|
505
|
+
if (scrapeResult.success) {
|
|
506
|
+
vSpinner.stop('Validated');
|
|
507
|
+
ui.success(`Cookie validated — ${formatUsageLine(scrapeResult.usage)}`);
|
|
508
|
+
return workspaceId;
|
|
509
|
+
}
|
|
510
|
+
vSpinner.error('Validation failed');
|
|
511
|
+
ui.error(`Validation failed: ${scrapeResult.error || 'Unknown error'}`);
|
|
512
|
+
const retry = ui.assertNotCancelled(await ui.confirm({
|
|
513
|
+
message: 'Try again?',
|
|
514
|
+
initialValue: true,
|
|
515
|
+
}));
|
|
516
|
+
if (!retry)
|
|
517
|
+
return null;
|
|
518
|
+
const newId = ui.assertNotCancelled(await ui.text({
|
|
519
|
+
message: 'Workspace ID (wrk_...)',
|
|
520
|
+
validate: (v) => {
|
|
521
|
+
if (!v || !/^wrk_[A-Za-z0-9]+$/.test(v))
|
|
522
|
+
return 'Must match format: wrk_... (e.g. wrk_abc123).';
|
|
523
|
+
},
|
|
524
|
+
}));
|
|
525
|
+
const retrySpinner = ui.spinner();
|
|
526
|
+
retrySpinner.start(`Retrying validation with workspace ${newId}...`);
|
|
527
|
+
const retryResult = await scrapeDashboard(newId, authCookie);
|
|
528
|
+
if (retryResult.success) {
|
|
529
|
+
retrySpinner.stop('Validated');
|
|
530
|
+
ui.success(`Cookie validated — ${formatUsageLine(retryResult.usage)}`);
|
|
531
|
+
return newId;
|
|
532
|
+
}
|
|
533
|
+
retrySpinner.error('Validation failed');
|
|
534
|
+
ui.error(`Still failed: ${retryResult.error || 'Unknown'}`);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
async function writeConfigFile(cfg, configDir, encryptionEnabled) {
|
|
538
|
+
const yaml = generateYaml(cfg, encryptionEnabled);
|
|
539
|
+
backupExistingConfig(configDir);
|
|
540
|
+
mkdirSync(configDir, { recursive: true });
|
|
541
|
+
const configPath = pathResolve(configDir, 'config.yaml');
|
|
542
|
+
writeFileSync(configPath, yaml, { encoding: 'utf-8', mode: 0o600 });
|
|
543
|
+
ui.success(`config.yaml written to ${configPath}`);
|
|
544
|
+
return configPath;
|
|
545
|
+
}
|
|
546
|
+
async function runSmokeTestIfEnabled(opts) {
|
|
547
|
+
if (opts.skipSmokeTest)
|
|
548
|
+
return;
|
|
549
|
+
ui.info(`Starting proxy smoke test on port ${opts.port}...`);
|
|
550
|
+
try {
|
|
551
|
+
await testProxy(opts.port, 20_000, opts.configDir, opts.encryptionKey, opts.configPath);
|
|
552
|
+
ui.success('Smoke test passed — proxy is working correctly');
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
556
|
+
ui.warn(`Smoke test warning: ${message}`);
|
|
557
|
+
ui.info('The config.yaml has been saved. You can troubleshoot by running:');
|
|
558
|
+
ui.info(' $ npm run dev');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function configureOpencodeClient(port) {
|
|
562
|
+
const defaultOpencodePath = getDefaultOpencodeConfigPath();
|
|
563
|
+
const opencodeExists = existsSync(defaultOpencodePath);
|
|
564
|
+
let opencodePath;
|
|
565
|
+
if (opencodeExists) {
|
|
566
|
+
opencodePath = defaultOpencodePath;
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
ui.info(`Default OpenCode config not found at ${defaultOpencodePath}`);
|
|
570
|
+
const customPath = ui.assertNotCancelled(await ui.text({
|
|
571
|
+
message: 'Path to opencode.json (or press Enter to skip)',
|
|
572
|
+
placeholder: 'Press Enter to skip',
|
|
573
|
+
validate: () => undefined,
|
|
574
|
+
}));
|
|
575
|
+
if (customPath?.trim()) {
|
|
576
|
+
opencodePath = customPath.trim();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!opencodePath) {
|
|
580
|
+
ui.info('Skipped OpenCode configuration. Add this to your opencode.json manually:');
|
|
581
|
+
console.log(chalk.dim(generateManualConfigSnippet(port)));
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
ui.info(`Found OpenCode config at ${opencodePath}`);
|
|
585
|
+
const shouldConfigure = ui.assertNotCancelled(await ui.confirm({
|
|
586
|
+
message: 'Configure opencode.json to use this proxy?',
|
|
587
|
+
initialValue: true,
|
|
588
|
+
}));
|
|
589
|
+
if (shouldConfigure) {
|
|
590
|
+
const result = updateOpencodeConfig(port, { configPath: opencodePath });
|
|
591
|
+
if (result.success) {
|
|
592
|
+
if (result.created) {
|
|
593
|
+
ui.success(`Created opencode.json at ${result.path}`);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
ui.success(`Updated opencode.json at ${result.path}`);
|
|
597
|
+
ui.info('A backup was saved to opencode.json.backup');
|
|
598
|
+
}
|
|
599
|
+
ui.info('If OpenCode is running, restart it to pick up the changes');
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
ui.warn(`Could not update opencode.json: ${result.error}`);
|
|
603
|
+
ui.info('You can manually add this to your opencode.json:');
|
|
604
|
+
console.log(chalk.dim(generateManualConfigSnippet(port)));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
ui.info('Skipped automatic configuration. Add this to your opencode.json manually:');
|
|
609
|
+
console.log(chalk.dim(generateManualConfigSnippet(port)));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
// Main
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
export async function setup(configDir = dirname(getDefaultConfigPath()), skipSmokeTest = false) {
|
|
616
|
+
ui.intro('Saros — Setup');
|
|
617
|
+
ui.step('Server Configuration');
|
|
618
|
+
const { port, upstreamBaseUrl } = await promptServerConfig();
|
|
619
|
+
const numAccounts = await promptAccountCount();
|
|
620
|
+
const { encryptionKey, envSaved } = await promptEncryption();
|
|
621
|
+
const scraping = await promptScrapingConfig();
|
|
622
|
+
const { keys, scrapingAccounts } = await collectAccountCredentials({
|
|
623
|
+
numAccounts,
|
|
624
|
+
encryptionKey,
|
|
625
|
+
wantScraping: scraping !== null,
|
|
626
|
+
});
|
|
627
|
+
if (encryptionKey && scrapingAccounts && scrapingAccounts.length > 0) {
|
|
628
|
+
ui.success(`${scrapingAccounts.length} cookie(s) encrypted`);
|
|
629
|
+
}
|
|
630
|
+
const cfg = {
|
|
631
|
+
port,
|
|
632
|
+
upstreamBaseUrl,
|
|
633
|
+
keys,
|
|
634
|
+
scrapingAccounts,
|
|
635
|
+
scrapingThreshold: scraping?.threshold,
|
|
636
|
+
scrapingIntervalMs: scraping?.intervalMs,
|
|
637
|
+
};
|
|
638
|
+
const configPath = await writeConfigFile(cfg, configDir, encryptionKey !== undefined);
|
|
639
|
+
await runSmokeTestIfEnabled({
|
|
640
|
+
port,
|
|
641
|
+
configDir,
|
|
642
|
+
encryptionKey,
|
|
643
|
+
configPath,
|
|
644
|
+
skipSmokeTest,
|
|
645
|
+
});
|
|
646
|
+
ui.step('OpenCode Client Configuration');
|
|
647
|
+
await configureOpencodeClient(port);
|
|
648
|
+
ui.printConfigSummary(cfg, encryptionKey !== undefined, envSaved);
|
|
649
|
+
ui.printNextSteps(cfg.port);
|
|
650
|
+
ui.outro('Setup complete');
|
|
651
|
+
}
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Entry point when run directly
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
// Only run if this is the main module
|
|
656
|
+
const isMain = process.argv[1] &&
|
|
657
|
+
(pathResolve(process.argv[1]) === pathResolve(fileURLToPath(import.meta.url)) ||
|
|
658
|
+
process.argv[1].endsWith('setup.ts'));
|
|
659
|
+
if (isMain) {
|
|
660
|
+
try {
|
|
661
|
+
await setup();
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
665
|
+
ui.outro(chalk.red(`Setup failed: ${message}`));
|
|
666
|
+
process.exit(1);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=setup.js.map
|