neoagent 1.2.0 → 1.4.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 +1 -1
- package/server/routes/store.js +61 -0
- package/server/services/manager.js +25 -18
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
|
@@ -34,7 +34,7 @@ if (!process.env.SESSION_SECRET) {
|
|
|
34
34
|
console.warn('WARNING: SESSION_SECRET not set — using insecure default. Set it in .env before exposing this server.');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const PORT = process.env.PORT ||
|
|
37
|
+
const PORT = process.env.PORT || 3333;
|
|
38
38
|
const DATA_DIR = path.join(__dirname, '../data');
|
|
39
39
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
40
40
|
|
package/server/routes/store.js
CHANGED
|
@@ -1047,6 +1047,67 @@ ruby <file>.rb # Ruby
|
|
|
1047
1047
|
},
|
|
1048
1048
|
|
|
1049
1049
|
// ── MAKER ────────────────────────────────────────────────────────────────────
|
|
1050
|
+
{
|
|
1051
|
+
id: 'psa-car-controller',
|
|
1052
|
+
name: 'PSA Car Controller',
|
|
1053
|
+
description: 'Control and query a local psa_car_controller instance for vehicle status, charging, climate, locks, lights and trips.',
|
|
1054
|
+
category: 'maker',
|
|
1055
|
+
icon: '🚗',
|
|
1056
|
+
content: `---
|
|
1057
|
+
name: psa-car-controller
|
|
1058
|
+
description: Control and query a local psa_car_controller instance for vehicle status, charging, climate, locks, lights and trips
|
|
1059
|
+
trigger: When the user asks about a Peugeot, Citroen, Opel, Vauxhall or DS vehicle connected through psa_car_controller, including status, charging, preconditioning, locks, horn, lights, trips, charging sessions, battery SOH or settings
|
|
1060
|
+
category: maker
|
|
1061
|
+
icon: 🚗
|
|
1062
|
+
enabled: true
|
|
1063
|
+
---
|
|
1064
|
+
|
|
1065
|
+
# PSA Car Controller
|
|
1066
|
+
|
|
1067
|
+
Use the local [flobz/psa_car_controller](https://github.com/flobz/psa_car_controller) HTTP API. Default base URL: \`http://localhost:5005\`. Only use another host if the user explicitly gives one.
|
|
1068
|
+
|
|
1069
|
+
## Request rules
|
|
1070
|
+
|
|
1071
|
+
- Use \`http_request\` when available; otherwise use \`curl\`.
|
|
1072
|
+
- Default to JSON output and show the exact endpoint you called.
|
|
1073
|
+
- If the user does not provide a VIN, call \`GET /settings\` first and infer it from the configured vehicle when possible. If there are multiple vehicles or no VIN is present, ask for the VIN.
|
|
1074
|
+
- For read-only status requests, prefer cache when freshness is not important: \`GET /get_vehicleinfo/<VIN>?from_cache=1\`.
|
|
1075
|
+
- For live status, use \`GET /get_vehicleinfo/<VIN>\`. If the user wants a refresh from the car first, call \`GET /wakeup/<VIN>\`, wait briefly, then fetch status.
|
|
1076
|
+
- For state-changing actions that could be safety-sensitive (unlock, horn, lights, climate, charge stop/start), make sure the user intent is explicit before calling them.
|
|
1077
|
+
- If changing settings via \`/settings/<section>\`, mention that the app needs a restart afterward.
|
|
1078
|
+
|
|
1079
|
+
## Supported endpoints
|
|
1080
|
+
|
|
1081
|
+
- Vehicle state: \`GET /get_vehicleinfo/<VIN>\`
|
|
1082
|
+
- Cached vehicle state: \`GET /get_vehicleinfo/<VIN>?from_cache=1\`
|
|
1083
|
+
- Wake up / refresh state: \`GET /wakeup/<VIN>\`
|
|
1084
|
+
- Start or stop preconditioning: \`GET /preconditioning/<VIN>/1\` or \`/0\`
|
|
1085
|
+
- Start or stop charge immediately: \`GET /charge_now/<VIN>/1\` or \`/0\`
|
|
1086
|
+
- Set charge stop hour: \`GET /charge_control?vin=<VIN>&hour=<H>&minute=<M>\`
|
|
1087
|
+
- Set charge threshold percentage: \`GET /charge_control?vin=<VIN>&percentage=<PERCENT>\`
|
|
1088
|
+
- Set scheduled charge hour: \`GET /charge_hour?vin=<VIN>&hour=<H>&minute=<M>\`
|
|
1089
|
+
- Honk horn: \`GET /horn/<VIN>/<COUNT>\`
|
|
1090
|
+
- Flash lights: \`GET /lights/<VIN>/<DURATION>\`
|
|
1091
|
+
- Lock or unlock doors: \`GET /lock_door/<VIN>/1\` or \`/0\`
|
|
1092
|
+
- Battery SOH: \`GET /battery/soh/<VIN>\`
|
|
1093
|
+
- Charging sessions: \`GET /vehicles/chargings\`
|
|
1094
|
+
- Trips: \`GET /vehicles/trips\`
|
|
1095
|
+
- Dashboard / root UI: \`GET /\`
|
|
1096
|
+
- Read settings: \`GET /settings\`
|
|
1097
|
+
- Update settings: \`GET /settings/<section>?key=value\`
|
|
1098
|
+
|
|
1099
|
+
## Response format
|
|
1100
|
+
|
|
1101
|
+
Reply with:
|
|
1102
|
+
- action performed
|
|
1103
|
+
- endpoint used
|
|
1104
|
+
- status/result
|
|
1105
|
+
- key fields from the JSON response
|
|
1106
|
+
- any follow-up note, such as restart needed for settings changes
|
|
1107
|
+
|
|
1108
|
+
If the API returns an error, include the response body and suggest the next useful check, usually \`/settings\` or a VIN validation.`
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1050
1111
|
{
|
|
1051
1112
|
id: 'bambu-studio-cli',
|
|
1052
1113
|
name: 'BambuStudio CLI',
|
|
@@ -124,28 +124,35 @@ async function startServices(app, io) {
|
|
|
124
124
|
if (msg.platform !== 'discord' && msg.platform !== 'telegram') {
|
|
125
125
|
const whitelistRow = db.prepare('SELECT value FROM user_settings WHERE user_id = ? AND key = ?')
|
|
126
126
|
.get(userId, `platform_whitelist_${msg.platform}`);
|
|
127
|
+
const normalize = msg.platform === 'whatsapp'
|
|
128
|
+
? normalizeWhatsAppId
|
|
129
|
+
: (id) => String(id || '').replace(/[^0-9+]/g, '');
|
|
130
|
+
|
|
131
|
+
let whitelist = [];
|
|
127
132
|
if (whitelistRow) {
|
|
128
133
|
try {
|
|
129
|
-
const
|
|
130
|
-
if (Array.isArray(
|
|
131
|
-
const normalize = msg.platform === 'whatsapp'
|
|
132
|
-
? normalizeWhatsAppId
|
|
133
|
-
: (id) => String(id || '').replace(/[^0-9+]/g, '');
|
|
134
|
-
const senderNorm = normalize(msg.sender || msg.chatId);
|
|
135
|
-
const allowed = whitelist.some((n) => normalize(n) === senderNorm);
|
|
136
|
-
if (!allowed) {
|
|
137
|
-
console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
|
|
138
|
-
io.to(`user:${userId}`).emit('messaging:blocked_sender', {
|
|
139
|
-
platform: msg.platform,
|
|
140
|
-
sender: msg.sender,
|
|
141
|
-
chatId: msg.chatId,
|
|
142
|
-
senderName: msg.senderName || null
|
|
143
|
-
});
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
134
|
+
const parsed = JSON.parse(whitelistRow.value);
|
|
135
|
+
if (Array.isArray(parsed)) whitelist = parsed;
|
|
147
136
|
} catch { }
|
|
148
137
|
}
|
|
138
|
+
|
|
139
|
+
const enforceEmptyWhitelist = msg.platform === 'whatsapp';
|
|
140
|
+
const shouldCheckWhitelist = whitelist.length > 0 || enforceEmptyWhitelist;
|
|
141
|
+
|
|
142
|
+
if (shouldCheckWhitelist) {
|
|
143
|
+
const senderNorm = normalize(msg.sender || msg.chatId);
|
|
144
|
+
const allowed = whitelist.some((n) => normalize(n) === senderNorm);
|
|
145
|
+
if (!allowed) {
|
|
146
|
+
console.log(`[Messaging] Blocked ${msg.platform} message from ${msg.sender} (not in whitelist)`);
|
|
147
|
+
io.to(`user:${userId}`).emit('messaging:blocked_sender', {
|
|
148
|
+
platform: msg.platform,
|
|
149
|
+
sender: msg.sender,
|
|
150
|
+
chatId: msg.chatId,
|
|
151
|
+
senderName: msg.senderName || null
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
const upsertSetting = db.prepare('INSERT OR REPLACE INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|