neoagent 1.1.3 → 1.3.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/.env.example +2 -2
- package/docs/configuration.md +2 -2
- package/lib/manager.js +111 -5
- package/package.json +1 -1
- package/server/index.js +23 -2
- package/server/public/js/app.js +49 -9
package/.env.example
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
PORT=
|
|
1
|
+
PORT=3333
|
|
2
2
|
NODE_ENV=development
|
|
3
3
|
SESSION_SECRET=change-this-to-a-random-secret-in-production
|
|
4
4
|
|
|
5
5
|
# Comma-separated list of allowed CORS origins (leave empty to block all cross-origin requests)
|
|
6
|
-
# Example: ALLOWED_ORIGINS=http://localhost:
|
|
6
|
+
# Example: ALLOWED_ORIGINS=http://localhost:3333,https://yourdomain.com
|
|
7
7
|
ALLOWED_ORIGINS=
|
|
8
8
|
|
|
9
9
|
# xAI API key — used for:
|
package/docs/configuration.md
CHANGED
|
@@ -8,7 +8,7 @@ All settings live in `.env` at the project root. Run `neoagent setup` to regener
|
|
|
8
8
|
|
|
9
9
|
| Variable | Default | Description |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| `PORT` | `
|
|
11
|
+
| `PORT` | `3333` | HTTP port |
|
|
12
12
|
| `SESSION_SECRET` | *(required)* | Random string for session signing — generate with `openssl rand -hex 32` |
|
|
13
13
|
| `NODE_ENV` | `production` | Set to `development` to enable verbose logs |
|
|
14
14
|
| `SECURE_COOKIES` | `false` | Set `true` when behind a TLS-terminating proxy |
|
|
@@ -39,7 +39,7 @@ Telegram, Discord, and WhatsApp tokens are stored in the database via the web UI
|
|
|
39
39
|
## Minimal `.env` example
|
|
40
40
|
|
|
41
41
|
```dotenv
|
|
42
|
-
PORT=
|
|
42
|
+
PORT=3333
|
|
43
43
|
SESSION_SECRET=change-me-to-something-random
|
|
44
44
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
45
45
|
```
|
package/lib/manager.js
CHANGED
|
@@ -58,15 +58,67 @@ function loadEnvPort() {
|
|
|
58
58
|
try {
|
|
59
59
|
const env = fs.readFileSync(ENV_FILE, 'utf8');
|
|
60
60
|
const line = env.split('\n').find((entry) => entry.startsWith('PORT='));
|
|
61
|
-
if (!line) return
|
|
61
|
+
if (!line) return 3333;
|
|
62
62
|
const raw = line.split('=')[1]?.trim();
|
|
63
63
|
const num = Number(raw);
|
|
64
|
-
return Number.isFinite(num) && num > 0 ? num :
|
|
64
|
+
return Number.isFinite(num) && num > 0 ? num : 3333;
|
|
65
65
|
} catch {
|
|
66
|
-
return
|
|
66
|
+
return 3333;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
function readEnvFileRaw() {
|
|
71
|
+
if (!fs.existsSync(ENV_FILE)) return '';
|
|
72
|
+
return fs.readFileSync(ENV_FILE, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseEnv(raw) {
|
|
76
|
+
const lines = raw.split('\n');
|
|
77
|
+
const map = new Map();
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
|
80
|
+
const idx = line.indexOf('=');
|
|
81
|
+
const key = line.slice(0, idx).trim();
|
|
82
|
+
const value = line.slice(idx + 1);
|
|
83
|
+
if (key) map.set(key, value);
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function upsertEnvValue(key, value) {
|
|
89
|
+
const raw = readEnvFileRaw();
|
|
90
|
+
const lines = raw ? raw.split('\n') : [];
|
|
91
|
+
let replaced = false;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
if (lines[i].startsWith(`${key}=`)) {
|
|
95
|
+
lines[i] = `${key}=${value}`;
|
|
96
|
+
replaced = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!replaced) lines.push(`${key}=${value}`);
|
|
102
|
+
const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
|
|
103
|
+
fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function removeEnvValue(key) {
|
|
107
|
+
const raw = readEnvFileRaw();
|
|
108
|
+
if (!raw) return false;
|
|
109
|
+
const lines = raw.split('\n').filter((line) => !line.startsWith(`${key}=`));
|
|
110
|
+
const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
|
|
111
|
+
fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function maskEnvValue(key, value) {
|
|
116
|
+
if (!/(KEY|TOKEN|SECRET|PASSWORD)/i.test(key)) return value;
|
|
117
|
+
const text = String(value || '');
|
|
118
|
+
if (text.length <= 8) return '********';
|
|
119
|
+
return `${text.slice(0, 4)}...${text.slice(-4)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
70
122
|
function runOrThrow(cmd, args, options = {}) {
|
|
71
123
|
const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: APP_DIR, ...options });
|
|
72
124
|
if (res.status !== 0) {
|
|
@@ -159,7 +211,7 @@ async function cmdSetup() {
|
|
|
159
211
|
}
|
|
160
212
|
}
|
|
161
213
|
|
|
162
|
-
const port = await ask('Server port', current.PORT || '
|
|
214
|
+
const port = await ask('Server port', current.PORT || '3333');
|
|
163
215
|
const sessionSecret = await ask('Session secret', current.SESSION_SECRET || randomSecret());
|
|
164
216
|
const anthropic = await ask('Anthropic API key', current.ANTHROPIC_API_KEY || '');
|
|
165
217
|
const openai = await ask('OpenAI API key', current.OPENAI_API_KEY || '');
|
|
@@ -419,10 +471,61 @@ function cmdUpdate() {
|
|
|
419
471
|
cmdRestart();
|
|
420
472
|
}
|
|
421
473
|
|
|
474
|
+
async function cmdEnv(args = []) {
|
|
475
|
+
heading('Environment Variables');
|
|
476
|
+
let action = args[0];
|
|
477
|
+
|
|
478
|
+
if (!action) {
|
|
479
|
+
const picked = await ask('Action (list/get/set/unset)', 'list');
|
|
480
|
+
action = (picked || 'list').trim().toLowerCase();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (action === 'list') {
|
|
484
|
+
const env = parseEnv(readEnvFileRaw());
|
|
485
|
+
if (env.size === 0) {
|
|
486
|
+
logWarn(`No .env found at ${ENV_FILE}`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
for (const [k, v] of [...env.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
490
|
+
console.log(`${k}=${maskEnvValue(k, v)}`);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (action === 'get') {
|
|
496
|
+
const key = args[1] || await ask('Key', 'PORT');
|
|
497
|
+
if (!key) throw new Error('Usage: neoagent env get <KEY>');
|
|
498
|
+
const env = parseEnv(readEnvFileRaw());
|
|
499
|
+
if (!env.has(key)) throw new Error(`Key not found: ${key}`);
|
|
500
|
+
console.log(env.get(key));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (action === 'set') {
|
|
505
|
+
const key = args[1] || await ask('Key', 'PORT');
|
|
506
|
+
const value = args.slice(2).join(' ') || await ask('Value', key === 'PORT' ? '3333' : '');
|
|
507
|
+
if (!key || !value) throw new Error('Usage: neoagent env set <KEY> <VALUE>');
|
|
508
|
+
upsertEnvValue(key, value);
|
|
509
|
+
logOk(`Set ${key} in ${ENV_FILE}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (action === 'unset') {
|
|
514
|
+
const key = args[1] || await ask('Key', 'PORT');
|
|
515
|
+
if (!key) throw new Error('Usage: neoagent env unset <KEY>');
|
|
516
|
+
removeEnvValue(key);
|
|
517
|
+
logOk(`Removed ${key} from ${ENV_FILE}`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
throw new Error('Usage: neoagent env [list|get|set|unset] ...');
|
|
522
|
+
}
|
|
523
|
+
|
|
422
524
|
function printHelp() {
|
|
423
525
|
console.log(`${APP_NAME} manager`);
|
|
424
526
|
console.log('Usage: neoagent <command>');
|
|
425
|
-
console.log('Commands: install | setup | update | restart | start | stop | status | logs | uninstall');
|
|
527
|
+
console.log('Commands: install | setup | env | update | restart | start | stop | status | logs | uninstall');
|
|
528
|
+
console.log('Env usage: neoagent env list | neoagent env get PORT | neoagent env set PORT 3333 | neoagent env unset PORT');
|
|
426
529
|
}
|
|
427
530
|
|
|
428
531
|
async function runCLI(argv) {
|
|
@@ -435,6 +538,9 @@ async function runCLI(argv) {
|
|
|
435
538
|
case 'setup':
|
|
436
539
|
await cmdSetup();
|
|
437
540
|
break;
|
|
541
|
+
case 'env':
|
|
542
|
+
await cmdEnv(argv.slice(1));
|
|
543
|
+
break;
|
|
438
544
|
case 'update':
|
|
439
545
|
cmdUpdate();
|
|
440
546
|
break;
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const { sanitizeError } = require('./utils/security');
|
|
|
16
16
|
const { setupConsoleInterceptor } = require('./utils/logger');
|
|
17
17
|
const { setupTelnyxWebhook } = require('./routes/telnyx');
|
|
18
18
|
const { startServices } = require('./services/manager');
|
|
19
|
+
const packageJson = require('../package.json');
|
|
19
20
|
|
|
20
21
|
const app = express();
|
|
21
22
|
const httpServer = createServer(app);
|
|
@@ -33,7 +34,7 @@ if (!process.env.SESSION_SECRET) {
|
|
|
33
34
|
console.warn('WARNING: SESSION_SECRET not set — using insecure default. Set it in .env before exposing this server.');
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const PORT = process.env.PORT ||
|
|
37
|
+
const PORT = process.env.PORT || 3333;
|
|
37
38
|
const DATA_DIR = path.join(__dirname, '../data');
|
|
38
39
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
39
40
|
|
|
@@ -53,7 +54,7 @@ app.use(helmet({
|
|
|
53
54
|
contentSecurityPolicy: {
|
|
54
55
|
directives: {
|
|
55
56
|
defaultSrc: ["'self'"],
|
|
56
|
-
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
57
|
+
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
|
|
57
58
|
scriptSrcAttr: ["'unsafe-inline'"],
|
|
58
59
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
|
59
60
|
imgSrc: ["'self'", "data:", "blob:", "https://api.qrserver.com"],
|
|
@@ -153,6 +154,26 @@ app.get('/api/health', requireAuth, (req, res) => {
|
|
|
153
154
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
154
155
|
});
|
|
155
156
|
|
|
157
|
+
app.get('/api/version', requireAuth, (req, res) => {
|
|
158
|
+
let gitSha = null;
|
|
159
|
+
try {
|
|
160
|
+
const { execSync } = require('child_process');
|
|
161
|
+
gitSha = execSync('git rev-parse --short HEAD', {
|
|
162
|
+
cwd: path.join(__dirname, '..'),
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
165
|
+
}).trim();
|
|
166
|
+
} catch {
|
|
167
|
+
gitSha = process.env.GIT_SHA || null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
res.json({
|
|
171
|
+
name: packageJson.name,
|
|
172
|
+
version: packageJson.version,
|
|
173
|
+
gitSha
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
156
177
|
// ── Service Initialization ──
|
|
157
178
|
// Handled by services/manager.js
|
|
158
179
|
|
package/server/public/js/app.js
CHANGED
|
@@ -44,9 +44,22 @@ async function api(path, opts = {}) {
|
|
|
44
44
|
...opts,
|
|
45
45
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
46
46
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
|
|
48
|
+
const contentType = (res.headers.get("content-type") || "").toLowerCase();
|
|
49
|
+
let data = null;
|
|
50
|
+
if (contentType.includes("application/json")) {
|
|
51
|
+
data = await res.json();
|
|
52
|
+
} else {
|
|
53
|
+
const text = await res.text();
|
|
54
|
+
data = { error: text || `Request failed (${res.status})` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = new Error(data?.error || `Request failed (${res.status})`);
|
|
59
|
+
err.status = res.status;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
return data || {};
|
|
50
63
|
}
|
|
51
64
|
|
|
52
65
|
function escapeHtml(str) {
|
|
@@ -1178,6 +1191,7 @@ if (copyLogsBtn) {
|
|
|
1178
1191
|
|
|
1179
1192
|
let updateStatusPollTimer = null;
|
|
1180
1193
|
let updateFinishNotifiedAt = null;
|
|
1194
|
+
let backendVersionLabel = null;
|
|
1181
1195
|
|
|
1182
1196
|
function clearUpdatePoll() {
|
|
1183
1197
|
if (updateStatusPollTimer) {
|
|
@@ -1217,7 +1231,9 @@ function renderUpdateStatus(status) {
|
|
|
1217
1231
|
|
|
1218
1232
|
const before = status?.versionBefore || "—";
|
|
1219
1233
|
const after = status?.versionAfter || "—";
|
|
1220
|
-
|
|
1234
|
+
const updateVersionLabel = `${before}${after !== "—" ? ` -> ${after}` : ""}`;
|
|
1235
|
+
const backendLabel = backendVersionLabel ? ` | Backend: ${backendVersionLabel}` : "";
|
|
1236
|
+
$("#updateVersionMeta").textContent = `Update Version: ${updateVersionLabel}${backendLabel}`;
|
|
1221
1237
|
|
|
1222
1238
|
const changelog = $("#updateChangelog");
|
|
1223
1239
|
changelog.innerHTML = "";
|
|
@@ -1265,7 +1281,17 @@ async function refreshUpdateStatus() {
|
|
|
1265
1281
|
clearUpdatePoll();
|
|
1266
1282
|
}
|
|
1267
1283
|
} catch (err) {
|
|
1268
|
-
// During restart window
|
|
1284
|
+
// During restart window this can fail briefly; keep trying.
|
|
1285
|
+
// If endpoint is unavailable (older backend), stop polling to avoid console spam.
|
|
1286
|
+
if (err?.status === 404) {
|
|
1287
|
+
clearUpdatePoll();
|
|
1288
|
+
$("#updatePhaseLabel").textContent = "Update status unavailable on this server version.";
|
|
1289
|
+
$("#updatePercentLabel").textContent = "—";
|
|
1290
|
+
setUpdateBadgeState("idle");
|
|
1291
|
+
const btn = $("#updateAppBtn");
|
|
1292
|
+
if (btn) btn.disabled = false;
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1269
1295
|
$("#updatePhaseLabel").textContent = "Reconnecting to server…";
|
|
1270
1296
|
setUpdateBadgeState("running");
|
|
1271
1297
|
}
|
|
@@ -1295,12 +1321,25 @@ function renderTokenUsageSummary(summary) {
|
|
|
1295
1321
|
|
|
1296
1322
|
$("#settingsBtn").addEventListener("click", async () => {
|
|
1297
1323
|
try {
|
|
1298
|
-
const [meta, settings
|
|
1324
|
+
const [meta, settings] = await Promise.all([
|
|
1299
1325
|
api("/settings/meta/models"),
|
|
1300
|
-
api("/settings")
|
|
1301
|
-
api("/settings/token-usage/summary")
|
|
1326
|
+
api("/settings")
|
|
1302
1327
|
]);
|
|
1303
|
-
|
|
1328
|
+
|
|
1329
|
+
try {
|
|
1330
|
+
const backendVersion = await api("/version");
|
|
1331
|
+
backendVersionLabel = `${backendVersion?.version || "unknown"}${backendVersion?.gitSha ? ` (${backendVersion.gitSha})` : ""}`;
|
|
1332
|
+
} catch {
|
|
1333
|
+
backendVersionLabel = "unavailable";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
try {
|
|
1337
|
+
const tokenUsage = await api("/settings/token-usage/summary");
|
|
1338
|
+
renderTokenUsageSummary(tokenUsage);
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
const tokenBox = $("#tokenUsageSummary");
|
|
1341
|
+
if (tokenBox) tokenBox.textContent = "Token usage unavailable on this server version.";
|
|
1342
|
+
}
|
|
1304
1343
|
|
|
1305
1344
|
$("#settingHeartbeat").checked =
|
|
1306
1345
|
settings.heartbeat_enabled === true ||
|
|
@@ -1373,6 +1412,7 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1373
1412
|
$("#settingHeadlessBrowser").checked = true; // default headless
|
|
1374
1413
|
const tokenBox = $("#tokenUsageSummary");
|
|
1375
1414
|
if (tokenBox) tokenBox.textContent = "Token usage unavailable.";
|
|
1415
|
+
backendVersionLabel = "unavailable";
|
|
1376
1416
|
}
|
|
1377
1417
|
await refreshUpdateStatus();
|
|
1378
1418
|
ensureUpdatePolling(true);
|