neuroalpha 0.6.13 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +471 -166
  8. package/public/index.html +9 -39
  9. package/public/styles.css +42 -3
  10. package/server/api/routes.js +595 -906
  11. package/server/auth/index.js +583 -761
  12. package/server/brokers/index.js +14 -3
  13. package/server/config.js +73 -53
  14. package/server/index.js +186 -212
  15. package/server/llm/local-engine.js +843 -0
  16. package/server/llm/providers.js +45 -34
  17. package/server/model/live.js +2 -1
  18. package/server/notify/index.js +304 -466
  19. package/server/strategist/index.js +4 -1
  20. package/deploy/docs/01-central.md +0 -96
  21. package/deploy/docs/02-cloud-instances.md +0 -130
  22. package/deploy/docs/03-desktop-build.md +0 -92
  23. package/deploy/docs/04-client-guide.md +0 -101
  24. package/deploy/docs/05-operations.md +0 -109
  25. package/deploy/docs/06-env-reference.md +0 -91
  26. package/deploy/docs/07-stress-test.md +0 -253
  27. package/deploy/docs/pdf/01-central.pdf +0 -0
  28. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  29. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  30. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  31. package/deploy/docs/pdf/05-operations.pdf +0 -0
  32. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  33. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  34. package/deploy/install.sh +0 -128
  35. package/deploy/k8s/instance.yaml +0 -147
  36. package/public/admin.html +0 -604
  37. package/public/landing.html +0 -1207
  38. package/public/legal.html +0 -134
  39. package/scripts/set-admin-password.mjs +0 -30
  40. package/server/billing/yookassa.js +0 -142
  41. package/server/instance/agent.js +0 -259
  42. package/server/instance/central.js +0 -514
  43. package/server/instance/identity.js +0 -79
  44. package/server/instance/provision.js +0 -166
  45. package/server/instance/signing.js +0 -71
  46. package/server/role.js +0 -44
@@ -5,7 +5,7 @@ import https from 'node:https';
5
5
  import path from 'node:path';
6
6
  import { ROOT } from '../config.js';
7
7
  import { db, kvGet, kvSet, userKvGet, userKvSet, getRun } from '../db.js';
8
- import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser } from '../auth/index.js';
8
+ import { encryptSecret, decryptSecret, currentUser, currentUserId, effectivePlan, runWithUser, tradingGate } from '../auth/index.js';
9
9
  import { notifyBrokerBatch, registerBrokerHandlers } from '../notify/index.js';
10
10
  import { getSettings, normalizeParams } from '../settings.js';
11
11
  import { latestBarTs, registerRealtimeCandleFetcher } from '../data/moex.js';
@@ -22,7 +22,6 @@ import { blockingNews } from '../news/index.js';
22
22
  import { round, MSK, DAY, fmtBarMsk } from '../util.js';
23
23
  import { hub } from '../hub.js';
24
24
  import { TRUSTED_CA } from '../tls-ca.js';
25
- import { tradingGate } from '../instance/agent.js';
26
25
 
27
26
  export const FULLY_SUPPORTED_BROKERS = ['tbank'];
28
27
  const MANUAL_BROKERS = ['sber', 'vtb', 'alfa'];
@@ -529,14 +528,26 @@ export function getActiveAccountSignalFilters(nowTs = Date.now()) {
529
528
  }
530
529
 
531
530
  export function brokersState() {
531
+ const gate = tradingGate();
532
+ if (!gate.allowNew) {
533
+ return {
534
+ brokers: BROKER_META,
535
+ accounts: [],
536
+ trading: gate,
537
+ unlicensed: true,
538
+ };
539
+ }
532
540
  return {
533
541
  brokers: BROKER_META,
534
542
  accounts: loadRawAccounts().map(publicAccount),
535
- trading: tradingGate(),
543
+ trading: gate,
544
+ unlicensed: false,
536
545
  };
537
546
  }
538
547
 
539
548
  export function saveBrokerAccount(input = {}) {
549
+ const gate = tradingGate();
550
+ if (!gate.allowNew) throw new Error(gate.reason);
540
551
  const list = loadRawAccounts();
541
552
  const plan = effectivePlan(currentUser());
542
553
  const id = input.id && String(input.id).trim() ? String(input.id).trim() : `brk_${crypto.randomBytes(4).toString('hex')}`;
package/server/config.js CHANGED
@@ -1,53 +1,73 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- import { IS_PACKAGED, BUILD } from './build-info.js';
7
-
8
- // В защищённой сборке файлы интерфейса распаковываются во временный каталог (NEUROALPHA_ROOT задаёт загрузчик)
9
- export const ROOT = IS_PACKAGED
10
- ? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
11
- : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
- export const DATA_DIR = process.env.NEUROALPHA_DATA_DIR
13
- ? path.resolve(process.env.NEUROALPHA_DATA_DIR)
14
- : path.join(ROOT, 'data');
15
-
16
- if (!IS_PACKAGED) {
17
- const envFile = path.join(ROOT, '.env');
18
- if (fs.existsSync(envFile)) process.loadEnvFile(envFile);
19
- const cwdEnv = path.resolve(process.cwd(), '.env');
20
- if (cwdEnv !== envFile && fs.existsSync(cwdEnv)) process.loadEnvFile(cwdEnv);
21
- }
22
-
23
- export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
24
-
25
- function resolveApiKey() {
26
- if (process.env.LLM_API_KEY) return process.env.LLM_API_KEY.trim();
27
- const file = process.env.LLM_API_KEY_FILE;
28
- if (!file) return '';
29
- try {
30
- const raw = fs.readFileSync(path.resolve(ROOT, file), 'utf8').trim();
31
- if (raw.startsWith('{')) {
32
- const j = JSON.parse(raw);
33
- return String(j.apiKey || j.api_key || j.key || '').trim();
34
- }
35
- return raw;
36
- } catch {
37
- return '';
38
- }
39
- }
40
-
41
- export const llmConfig = {
42
- provider: (process.env.LLM_PROVIDER || 'openai').toLowerCase(),
43
- baseUrl: process.env.LLM_BASE_URL || 'https://models.odant.org/v1/chat/completions',
44
- model: process.env.LLM_MODEL || 'Qwen/Qwen3.8-27B-FP8',
45
- apiKey: resolveApiKey(),
46
- reasoningEffort: process.env.LLM_REASONING_EFFORT || 'low',
47
- maxTokens: Number(process.env.LLM_MAX_TOKENS || 4000),
48
- timeoutMs: Number(process.env.LLM_TIMEOUT_MS || 90000),
49
- };
50
- llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
51
-
52
- export const PORT = Number(process.env.PORT || 3000);
53
- export const DEFAULT_CLOUD_URL = ((IS_PACKAGED ? BUILD.centralUrl : process.env.CLOUD_URL) || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { IS_PACKAGED, BUILD } from './build-info.js';
7
+
8
+ export const ROOT = IS_PACKAGED
9
+ ? path.resolve(process.env.NEUROALPHA_ROOT || process.cwd())
10
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+
12
+ // Признак запуска из локального git-репозитория разработчика (вне node_modules / кэша npx)
13
+ const insideNodeModules = /[\\/]node_modules[\\/]|_npx/i.test(ROOT);
14
+ export const IS_DEV_REPO = !IS_PACKAGED && !insideNodeModules && fs.existsSync(path.join(ROOT, '.git'));
15
+
16
+ // Постоянный каталог данных терминала:
17
+ // • при запуске через npx neuroalpha@latest данные ВСЕГДА живут в ~/.neuroalpha и не пропадают при обновлениях;
18
+ // • при локальной разработке из git-репозитория (если есть ./data/trading.db или .git) сохраняется ./data.
19
+ function resolveDataDir() {
20
+ if (process.env.NEUROALPHA_DATA_DIR) return path.resolve(process.env.NEUROALPHA_DATA_DIR);
21
+ const repoData = path.join(ROOT, 'data');
22
+ if (!IS_PACKAGED && !insideNodeModules && (IS_DEV_REPO || fs.existsSync(path.join(repoData, 'trading.db')))) {
23
+ return repoData;
24
+ }
25
+ return path.join(os.homedir(), '.neuroalpha');
26
+ }
27
+
28
+ export const DATA_DIR = resolveDataDir();
29
+ fs.mkdirSync(DATA_DIR, { recursive: true });
30
+
31
+ if (!IS_PACKAGED) {
32
+ for (const envFile of [path.join(DATA_DIR, '.env'), path.join(ROOT, '.env'), path.resolve(process.cwd(), '.env')]) {
33
+ if (fs.existsSync(envFile)) {
34
+ try { process.loadEnvFile(envFile); } catch { /* ignore */ }
35
+ }
36
+ }
37
+ }
38
+
39
+ export const strategyDefaults = JSON.parse(fs.readFileSync(path.join(ROOT, 'config/strategy.json'), 'utf8'));
40
+
41
+ function resolveApiKey() {
42
+ if (process.env.LLM_API_KEY) return process.env.LLM_API_KEY.trim();
43
+ const file = process.env.LLM_API_KEY_FILE;
44
+ if (!file) return '';
45
+ try {
46
+ const raw = fs.readFileSync(path.resolve(ROOT, file), 'utf8').trim();
47
+ if (raw.startsWith('{')) {
48
+ const j = JSON.parse(raw);
49
+ return String(j.apiKey || j.api_key || j.key || '').trim();
50
+ }
51
+ return raw;
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ export const llmConfig = {
58
+ provider: (process.env.LLM_PROVIDER || 'openai').toLowerCase(),
59
+ baseUrl: process.env.LLM_BASE_URL || 'https://models.odant.org/v1/chat/completions',
60
+ model: process.env.LLM_MODEL || 'Qwen/Qwen3.8-27B-FP8',
61
+ apiKey: resolveApiKey(),
62
+ reasoningEffort: process.env.LLM_REASONING_EFFORT || 'low',
63
+ maxTokens: Number(process.env.LLM_MAX_TOKENS || 4000),
64
+ timeoutMs: Number(process.env.LLM_TIMEOUT_MS || 90000),
65
+ };
66
+ llmConfig.enabled = llmConfig.provider !== 'none' && !!llmConfig.baseUrl && !!llmConfig.model && !!llmConfig.apiKey;
67
+
68
+ export const PORT = Number(process.env.PORT || 3000);
69
+ export const LISTEN_HOST = String(process.env.HOST || '').trim() || undefined;
70
+ export const DEFAULT_CLOUD_URL = (
71
+ (IS_PACKAGED ? BUILD.centralUrl : (process.env.PORTAL_URL || process.env.CLOUD_URL))
72
+ || 'https://neuroalpha.odant.org'
73
+ ).replace(/\/+$/, '');
package/server/index.js CHANGED
@@ -1,212 +1,186 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import http from 'node:http';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import { spawn } from 'node:child_process';
6
- import path from 'node:path';
7
- import express from 'express';
8
- import { WebSocketServer } from 'ws';
9
- import { ROOT, PORT } from './config.js';
10
- import { ROLE, IS_INSTANCE, IS_DESKTOP, IS_CENTRAL, INSTANCE_KIND, LISTEN_HOST } from './role.js';
11
- import { startInstanceAgent, setInstanceCommandHandler } from './instance/agent.js';
12
- import { getIdentity, isActivated } from './instance/identity.js';
13
- import { startCentralReconciler } from './instance/central.js';
14
- import { DEFAULT_CLOUD_URL } from './config.js';
15
- import { activeModelInfo } from './llm/client.js';
16
- import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
17
- import { createRouter } from './api/routes.js';
18
- import { authMiddleware, activateLocalLicense, userFromRequest, runWithUser, getUserById } from './auth/index.js';
19
- import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
20
- import { live } from './model/live.js';
21
- import { runningResearch, runningStability } from './model/research.js';
22
- import { startNews } from './news/index.js';
23
- import { startNotifyPolling } from './notify/index.js';
24
- import { startBrokerScheduler, brokersState, executeBrokerOrders, rejectBrokerOrders } from './brokers/index.js';
25
- import { chatState, askFromMessenger } from './strategist/index.js';
26
- import { hub } from './hub.js';
27
-
28
- markStaleRuns();
29
- removeLegacyData();
30
- removeOrphanEvents();
31
- ensureSeedRun();
32
-
33
- const app = express();
34
- app.disable('x-powered-by');
35
- app.use(express.json({ limit: '1mb' }));
36
- app.use(securityHeaders);
37
- app.use(authMiddleware);
38
- const server = http.createServer(app);
39
- const wss = new WebSocketServer({ server, path: '/ws' });
40
-
41
- hub.setSender((msg, { uid, global }) => {
42
- const data = JSON.stringify(msg);
43
- for (const c of wss.clients) {
44
- if (c.readyState !== 1) continue;
45
- if (global || c.uid === uid) c.send(data);
46
- }
47
- });
48
-
49
- wss.on('connection', (ws, req) => {
50
- if (isCrossOrigin(req)) {
51
- ws.close(4403, 'forbidden origin');
52
- return;
53
- }
54
- const user = userFromRequest(req);
55
- if (!user) {
56
- ws.close(4401, 'unauthorized');
57
- return;
58
- }
59
- ws.uid = user.id;
60
- runWithUser(user, () => {
61
- ws.send(JSON.stringify({ type: 'hello', live: live.status(), chat: chatState(), running: runningResearch(), stabilityRunning: runningStability(), llm: activeModelInfo() }));
62
- });
63
- });
64
-
65
- // Команды из Telegram / MAX, пришедшие с центра: подтверждение пакета заявок или вопрос к Стратегу
66
- setInstanceCommandHandler((cmd) => runWithUser(getUserById(1), async () => {
67
- if (cmd.type === 'chat' && cmd.text) {
68
- const res = await askFromMessenger(cmd.text, { channel: cmd.channel || 'tg' });
69
- return `🤖 Стратег:\n\n${String(res?.text || 'Готово.').slice(0, 3400)}`;
70
- }
71
- if (cmd.type !== 'batch') return null;
72
- const acc = brokersState().accounts.find((a) => a.id === cmd.accountId);
73
- if (!acc) return 'Команда из мессенджера: счёт не найден.';
74
- const pb = acc.pendingBatch;
75
- if (!pb || pb.status !== 'pending' || (cmd.batchId && pb.id !== cmd.batchId)) {
76
- return `Счёт «${acc.name}»: этот пакет заявок уже неактуален (исполнен, отклонён или заменён новым) — команда не выполнена.`;
77
- }
78
- if (cmd.action === 'exec') {
79
- const st = await executeBrokerOrders(acc.id);
80
- const res = st.accounts.find((a) => a.id === acc.id)?.pendingBatch?.results || [];
81
- const ok = res.filter((r) => r.ok).length;
82
- return `✓ Счёт «${acc.name}»: отправлено брокеру ${ok} из ${res.length} заявок.${ok < res.length ? ` Ошибки: ${res.filter((r) => !r.ok).map((r) => `${r.symbol} — ${r.error}`).join('; ').slice(0, 600)}` : ''}`;
83
- }
84
- if (cmd.action === 'rej') {
85
- rejectBrokerOrders(acc.id);
86
- return `✕ Счёт «${acc.name}»: пакет заявок отклонён.`;
87
- }
88
- return null;
89
- }));
90
- startInstanceAgent();
91
- if (IS_CENTRAL) startCentralReconciler({ centralUrl: String(process.env.CENTRAL_PUBLIC_URL || DEFAULT_CLOUD_URL).replace(/\/+$/, '') });
92
- live.resume();
93
- startNews();
94
- startNotifyPolling();
95
- startBrokerScheduler();
96
-
97
- if (process.env.NEUROALPHA_API_KEY && !IS_INSTANCE) {
98
- activateLocalLicense(process.env.NEUROALPHA_API_KEY, process.env.CLOUD_URL || '')
99
- .then((lic) => console.log(`✓ Активирован API-ключ: тариф «${lic.planName}» (${lic.email})`))
100
- .catch((e) => console.warn(`⚠ Проверка API-ключа: ${e.message}`));
101
- }
102
-
103
- // Проверка живости для Kubernetes (liveness/readiness), без данных
104
- app.get('/healthz', (req, res) => res.json({ ok: true, role: ROLE }));
105
-
106
- // Машина клиента: витрина, оферта, админка и вход живут в центре; без сессии — вход через кабинет центра
107
- if (IS_INSTANCE) {
108
- const central = () => getIdentity().centralUrl;
109
- const toCentral = (p) => (req, res) => res.redirect(`${central()}${p}`);
110
- app.get(['/landing', '/landing.html'], toCentral('/landing'));
111
- app.get(['/legal', '/legal.html'], toCentral('/legal'));
112
- app.get(['/admin', '/admin.html'], toCentral('/landing'));
113
- app.get(['/', '/index.html'], (req, res, next) => {
114
- // установка на компьютер до активации — экран ввода кода из личного кабинета
115
- if (IS_DESKTOP && !isActivated()) {
116
- setAppCsp(res);
117
- return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
118
- }
119
- if (!req.user) return res.redirect(`${central()}/api/instances/open?i=${encodeURIComponent(getIdentity().id)}`);
120
- setAppCsp(res);
121
- next();
122
- });
123
- }
124
-
125
- app.get('/landing', (req, res) => res.sendFile(path.join(ROOT, 'public', 'landing.html')));
126
- app.get('/legal', (req, res) => res.sendFile(path.join(ROOT, 'public', 'legal.html')));
127
- app.get('/admin', (req, res) => {
128
- if (!req.user || req.user.role !== 'admin') return res.redirect('/landing');
129
- return res.sendFile(path.join(ROOT, 'public', 'admin.html'));
130
- });
131
- app.get('/', (req, res, next) => {
132
- // На центральном сервере (IS_CENTRAL) у клиентов на главном домене работает витрина и Личный кабинет (с биллингом и переходом на их PaaS-машину),
133
- // а локальный торговый терминал самого центра открывается только для администратора (или в dev/instance режиме)
134
- if (!req.user || (process.env.CLOUD_MODE === '1' && req.query.landing === '1') || (IS_CENTRAL && (req.user.role !== 'admin' || req.query.payment_check === '1') && req.query.terminal !== '1')) {
135
- return res.sendFile(path.join(ROOT, 'public', 'landing.html'));
136
- }
137
- setAppCsp(res);
138
- next();
139
- });
140
- app.use('/api', createRouter());
141
- app.use('/docs', express.static(path.join(ROOT, 'docs')));
142
- app.use(express.static(path.join(ROOT, 'public')));
143
-
144
- function lanUrls() {
145
- return Object.entries(os.networkInterfaces())
146
- .filter(([name]) => !/vethernet|wsl|docker|hyper-v|virtualbox|vmware/i.test(name))
147
- .flatMap(([, list]) => list || [])
148
- .filter((a) => a && a.family === 'IPv4' && !a.internal)
149
- .map((a) => `http://${a.address}:${PORT}`);
150
- }
151
-
152
- function fmtClickableUrl(url, out = process.stdout) {
153
- if (!out?.isTTY) return url;
154
- const osc8 = Boolean(
155
- process.env.WT_SESSION
156
- || process.env.TERM_PROGRAM
157
- || process.env.COLORTERM
158
- || process.env.FORCE_HYPERLINK === '1'
159
- || (process.platform !== 'win32' && process.env.TERM && process.env.TERM !== 'dumb'),
160
- );
161
- const styled = `\x1b[4;96m${url}\x1b[0m`;
162
- return osc8 ? `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\` : styled;
163
- }
164
-
165
- const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
166
- || (process.env.OPEN_BROWSER !== '0'
167
- && process.env.NO_BROWSER !== '1'
168
- && !process.argv.includes('--no-browser')
169
- && !process.execArgv.includes('--watch')
170
- && !IS_CENTRAL
171
- && (!IS_INSTANCE || IS_DESKTOP)
172
- && (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
173
-
174
- wss.on('error', () => {});
175
- server.on('error', (e) => {
176
- const clientUrl = `http://localhost:${PORT}`;
177
- if (e.code === 'EADDRINUSE') {
178
- console.error(`Порт ${PORT} занят: сервер уже запущен? Клиент: ${fmtClickableUrl(clientUrl, process.stderr)}`);
179
- if (shouldOpenBrowser) openBrowser(clientUrl);
180
- } else {
181
- console.error(e);
182
- }
183
- process.exit(1);
184
- });
185
-
186
- server.listen(PORT, LISTEN_HOST, () => {
187
- const ident = IS_INSTANCE ? getIdentity() : null;
188
- let ver = '';
189
- try { ver = ` v${JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version}`; } catch { /* ignore */ }
190
- console.log(`◈ НейроАльфа${ver} запущена${IS_INSTANCE ? ` (${INSTANCE_KIND}) · машина ${ident.id || 'не активирована'} · центр ${ident.centralUrl}` : (IS_CENTRAL ? ' · центр' : '')}`);
191
- const llm = activeModelInfo();
192
- console.log(` ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (Настройки → Модели ИИ)'}`);
193
- if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В сети: ${fmtClickableUrl(url)}`);
194
- const clientUrl = `http://localhost:${PORT}`;
195
- const enterHint = process.stdin?.isTTY && !IS_CENTRAL ? ' (Enter — открыть в браузере)' : '';
196
- console.log(` Клиент: ${fmtClickableUrl(clientUrl)}${enterHint}`);
197
- if (shouldOpenBrowser) openBrowser(clientUrl);
198
- if (process.stdin?.isTTY && !IS_CENTRAL) {
199
- process.stdin.on('data', () => openBrowser(clientUrl));
200
- process.stdin.unref?.();
201
- }
202
- });
203
-
204
- function openBrowser(url) {
205
- const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
206
- : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
207
- spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
208
- }
209
-
210
- const shutdown = () => { live.suspend().finally(() => server.close(() => process.exit(0))); setTimeout(() => process.exit(0), 2000); };
211
- process.on('SIGINT', shutdown);
212
- process.on('SIGTERM', shutdown);
1
+ // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
+ import http from 'node:http';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import express from 'express';
8
+ import { WebSocketServer } from 'ws';
9
+ import { ROOT, DATA_DIR, PORT, LISTEN_HOST, DEFAULT_CLOUD_URL } from './config.js';
10
+ import { activeModelInfo } from './llm/client.js';
11
+ import { registerLocalLlamaProvider } from './llm/providers.js';
12
+ import { setLocalModelReadyHook, resumeSavedLocalEngineOnStartup, getLocalEngineQuickStatus } from './llm/local-engine.js';
13
+ import { markStaleRuns, removeLegacyData, removeOrphanEvents, ensureSeedRun } from './db.js';
14
+ import { createRouter } from './api/routes.js';
15
+ import {
16
+ authMiddleware, activateLocalLicense, userFromRequest, runWithUser,
17
+ getTerminalLicenseState, startAutonomousLicenseWatcher,
18
+ } from './auth/index.js';
19
+ import { securityHeaders, setAppCsp, isCrossOrigin } from './security.js';
20
+ import { live } from './model/live.js';
21
+ import { runningResearch, runningStability } from './model/research.js';
22
+ import { startNews } from './news/index.js';
23
+ import { startNotifyPolling } from './notify/index.js';
24
+ import { startBrokerScheduler } from './brokers/index.js';
25
+ import { chatState } from './strategist/index.js';
26
+ import { hub } from './hub.js';
27
+
28
+ markStaleRuns();
29
+ removeLegacyData();
30
+ removeOrphanEvents();
31
+ ensureSeedRun();
32
+
33
+ setLocalModelReadyHook((info) => {
34
+ registerLocalLlamaProvider(info);
35
+ hub.emit({ type: 'llm:changed', llm: activeModelInfo() });
36
+ hub.emit({ type: 'chat:state', state: chatState() });
37
+ });
38
+
39
+ const app = express();
40
+ app.disable('x-powered-by');
41
+ app.use(express.json({ limit: '1mb' }));
42
+ app.use(securityHeaders);
43
+ app.use(authMiddleware);
44
+ const server = http.createServer(app);
45
+ const wss = new WebSocketServer({ server, path: '/ws' });
46
+
47
+ hub.setSender((msg, { uid, global }) => {
48
+ const data = JSON.stringify(msg);
49
+ for (const c of wss.clients) {
50
+ if (c.readyState !== 1) continue;
51
+ if (global || c.uid === uid) c.send(data);
52
+ }
53
+ });
54
+
55
+ wss.on('connection', (ws, req) => {
56
+ if (isCrossOrigin(req)) {
57
+ ws.close(4403, 'forbidden origin');
58
+ return;
59
+ }
60
+ const user = userFromRequest(req);
61
+ if (!user) {
62
+ ws.close(4401, 'unauthorized');
63
+ return;
64
+ }
65
+ ws.uid = user.id;
66
+ runWithUser(user, () => {
67
+ ws.send(JSON.stringify({
68
+ type: 'hello',
69
+ live: live.status(),
70
+ chat: chatState(),
71
+ running: runningResearch(),
72
+ stabilityRunning: runningStability(),
73
+ llm: activeModelInfo(),
74
+ localLlm: getLocalEngineQuickStatus(),
75
+ }));
76
+ });
77
+ });
78
+
79
+ startAutonomousLicenseWatcher();
80
+ resumeSavedLocalEngineOnStartup();
81
+ live.resume();
82
+ startNews();
83
+ startNotifyPolling();
84
+ startBrokerScheduler();
85
+
86
+ const cliToken = process.env.NEUROALPHA_TOKEN || process.env.NEUROALPHA_API_KEY || '';
87
+ if (cliToken && cliToken.startsWith('na_tok_')) {
88
+ activateLocalLicense(cliToken, process.env.PORTAL_URL || process.env.CLOUD_URL || '')
89
+ .then((lic) => console.log(` ✓ Активирована лицензия: тариф «${lic.planName}» (${lic.email})`))
90
+ .catch((e) => console.warn(` ⚠ Активация токена: ${e.message}`));
91
+ }
92
+
93
+ app.get('/healthz', (req, res) => res.json({ ok: true, app: 'neuroalpha' }));
94
+
95
+ app.get(['/legal', '/legal.html'], (req, res) => {
96
+ const portalUrl = getTerminalLicenseState().portalUrl || DEFAULT_CLOUD_URL;
97
+ res.redirect(`${portalUrl}/legal`);
98
+ });
99
+
100
+ // Если терминал ещё не активирован токеном из ЛК ИЛИ открыт по внешней сети/на VPS без сессии — показываем экран активации / входа
101
+ app.get(['/', '/index.html'], (req, res, next) => {
102
+ if (!req.user) {
103
+ setAppCsp(res);
104
+ return res.sendFile(path.join(ROOT, 'public', 'activate.html'));
105
+ }
106
+ setAppCsp(res);
107
+ return next();
108
+ });
109
+
110
+ app.use('/api', createRouter());
111
+ app.use('/docs', express.static(path.join(ROOT, 'docs')));
112
+ app.use(express.static(path.join(ROOT, 'public')));
113
+
114
+ function openBrowser(url) {
115
+ try {
116
+ if (process.platform === 'win32') {
117
+ spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true, windowsVerbatimArguments: true }).on('error', () => {}).unref();
118
+ } else if (process.platform === 'darwin') {
119
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
120
+ } else if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
121
+ spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
122
+ }
123
+ } catch { /* ignore */ }
124
+ }
125
+
126
+ function lanUrls() {
127
+ return Object.entries(os.networkInterfaces())
128
+ .filter(([name]) => !/vethernet|wsl|docker|hyper-v|virtualbox|vmware/i.test(name))
129
+ .flatMap(([, list]) => list || [])
130
+ .filter((a) => a && a.family === 'IPv4' && !a.internal)
131
+ .map((a) => `http://${a.address}:${PORT}`);
132
+ }
133
+
134
+ function fmtClickableUrl(url, out = process.stdout) {
135
+ if (!out?.isTTY) return url;
136
+ const osc8 = Boolean(
137
+ process.env.WT_SESSION
138
+ || process.env.TERM_PROGRAM
139
+ || process.env.COLORTERM
140
+ || process.env.FORCE_HYPERLINK === '1'
141
+ || (process.platform !== 'win32' && process.env.TERM && process.env.TERM !== 'dumb'),
142
+ );
143
+ const styled = `\x1b[4;96m${url}\x1b[0m`;
144
+ return osc8 ? `\x1b]8;;${url}\x1b\\${styled}\x1b]8;;\x1b\\` : styled;
145
+ }
146
+
147
+ const shouldOpenBrowser = process.env.OPEN_BROWSER === '1'
148
+ || (process.env.OPEN_BROWSER !== '0'
149
+ && process.env.NO_BROWSER !== '1'
150
+ && !process.argv.includes('--no-browser')
151
+ && !process.execArgv.includes('--watch')
152
+ && (process.platform === 'win32' || process.platform === 'darwin' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY)));
153
+
154
+ wss.on('error', () => {});
155
+ server.on('error', (e) => {
156
+ const clientUrl = `http://localhost:${PORT}`;
157
+ if (e.code === 'EADDRINUSE') {
158
+ console.error(`Порт ${PORT} занят: терминал уже запущен? Клиент: ${fmtClickableUrl(clientUrl, process.stderr)}`);
159
+ if (shouldOpenBrowser) openBrowser(clientUrl);
160
+ } else {
161
+ console.error(e);
162
+ }
163
+ process.exit(1);
164
+ });
165
+
166
+ server.listen(PORT, LISTEN_HOST, () => {
167
+ let ver = '';
168
+ try { ver = ` v${JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version}`; } catch { /* ignore */ }
169
+ const lic = getTerminalLicenseState();
170
+ const licInfo = lic.valid
171
+ ? `${lic.planName}${lic.perpetual ? ' (бессрочно)' : ''}`
172
+ : 'лицензия не активна (торговля отключена)';
173
+ console.log(`◈ НейроАльфа Терминал${ver} запущен · ${licInfo}`);
174
+ console.log(` Данные: ${DATA_DIR}`);
175
+ const llm = activeModelInfo();
176
+ console.log(` ИИ-стратег: ${llm.enabled ? `${llm.provider} · ${llm.model}` : 'выключен (Настройки → Модели ИИ)'}`);
177
+ if (!LISTEN_HOST || LISTEN_HOST === '0.0.0.0') for (const url of lanUrls()) console.log(` В сети: ${fmtClickableUrl(url)}`);
178
+ const clientUrl = `http://localhost:${PORT}`;
179
+ const enterHint = process.stdin?.isTTY ? ' (Enter — открыть в браузере)' : '';
180
+ console.log(` Клиент: ${fmtClickableUrl(clientUrl)}${enterHint}`);
181
+ if (shouldOpenBrowser) openBrowser(clientUrl);
182
+ if (process.stdin?.isTTY) {
183
+ process.stdin.on('data', () => openBrowser(clientUrl));
184
+ process.stdin.unref?.();
185
+ }
186
+ });