navada-edge-cli 3.4.1 → 3.5.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.
- package/lib/agent.js +81 -2
- package/lib/commands/audit.js +355 -0
- package/lib/commands/compute.js +225 -0
- package/lib/commands/edge.js +130 -2
- package/lib/commands/files.js +164 -0
- package/lib/commands/index.js +1 -1
- package/package.json +1 -1
package/lib/agent.js
CHANGED
|
@@ -21,7 +21,7 @@ const IDENTITY = {
|
|
|
21
21
|
You are professional, technical, concise, and helpful. You speak with authority about distributed systems, Docker, AI, and cloud infrastructure.
|
|
22
22
|
You have FULL ACCESS to the user's computer — you CAN and SHOULD use your tools to execute tasks:
|
|
23
23
|
- shell: run ANY bash, PowerShell, or system command on the user's machine
|
|
24
|
-
- read_file / write_file / list_files: full filesystem
|
|
24
|
+
- read_file / write_file / edit_file / delete_file / list_files: full filesystem CRUD — create, read, edit, delete any file
|
|
25
25
|
- python_exec / python_pip / python_script: run Python code directly
|
|
26
26
|
- sandbox_run: run code with syntax-highlighted output
|
|
27
27
|
- system_info: check CPU, RAM, disk, OS
|
|
@@ -34,6 +34,7 @@ You also connect to the NAVADA Edge Network (4 nodes via Tailscale VPN):
|
|
|
34
34
|
When users ask you to DO something — DO IT. Use write_file to create files. Use shell to run commands. Never say "I can't" when you have a tool for it.
|
|
35
35
|
When asked to generate diagrams — use write_file to create Mermaid (.mmd), SVG, or HTML files. You can also use python_exec with matplotlib/graphviz for complex diagrams.
|
|
36
36
|
When asked to create, edit, or delete files — use the file tools directly. You are a terminal agent with FULL access.
|
|
37
|
+
PLATFORM: This machine runs ` + (process.platform === 'win32' ? `Windows. Use Windows paths (C:\\Users\\...) not Unix paths (~/...). Desktop = ${path.join(os.homedir(), 'Desktop')}. Home = ${os.homedir()}.` : `${process.platform}. Home = ${os.homedir()}.`) + `
|
|
37
38
|
Keep responses short. Code blocks when needed. No fluff.`,
|
|
38
39
|
founder: {
|
|
39
40
|
name: 'Leslie (Lee) Akpareva',
|
|
@@ -67,6 +68,7 @@ Keep responses short. Code blocks when needed. No fluff.`,
|
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
function getSystemPrompt() {
|
|
71
|
+
// Learning mode overrides everything
|
|
70
72
|
if (sessionState.learningMode) {
|
|
71
73
|
try {
|
|
72
74
|
const { MODES } = require('./commands/learn');
|
|
@@ -74,9 +76,41 @@ function getSystemPrompt() {
|
|
|
74
76
|
if (mode) return mode.system;
|
|
75
77
|
} catch {}
|
|
76
78
|
}
|
|
79
|
+
|
|
80
|
+
// Load user's agent.md customisation if it exists
|
|
81
|
+
const agentMdPath = path.join(config.CONFIG_DIR, 'agent.md');
|
|
82
|
+
let userPrompt = '';
|
|
83
|
+
try {
|
|
84
|
+
if (fs.existsSync(agentMdPath)) {
|
|
85
|
+
userPrompt = fs.readFileSync(agentMdPath, 'utf-8').trim();
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
|
|
89
|
+
// Load active sub-agent if selected
|
|
90
|
+
if (sessionState.subAgent) {
|
|
91
|
+
const subPath = path.join(config.CONFIG_DIR, 'agents', `${sessionState.subAgent}.md`);
|
|
92
|
+
try {
|
|
93
|
+
if (fs.existsSync(subPath)) {
|
|
94
|
+
return fs.readFileSync(subPath, 'utf-8').trim();
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Combine: base personality + user customisation
|
|
100
|
+
if (userPrompt) {
|
|
101
|
+
return `${IDENTITY.personality}\n\n--- USER CUSTOMISATION (from agent.md) ---\n${userPrompt}`;
|
|
102
|
+
}
|
|
77
103
|
return IDENTITY.personality;
|
|
78
104
|
}
|
|
79
105
|
|
|
106
|
+
function listSubAgents() {
|
|
107
|
+
const dir = path.join(config.CONFIG_DIR, 'agents');
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(dir)) return [];
|
|
110
|
+
return fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.replace('.md', ''));
|
|
111
|
+
} catch { return []; }
|
|
112
|
+
}
|
|
113
|
+
|
|
80
114
|
// ---------------------------------------------------------------------------
|
|
81
115
|
// Session state — exposed for UI panels
|
|
82
116
|
// ---------------------------------------------------------------------------
|
|
@@ -88,6 +122,7 @@ const sessionState = {
|
|
|
88
122
|
messages: 0,
|
|
89
123
|
startTime: Date.now(),
|
|
90
124
|
learningMode: null, // 'python' | 'csharp' | 'node' | null
|
|
125
|
+
subAgent: null, // active sub-agent name (loads from ~/.navada/agents/<name>.md)
|
|
91
126
|
history: [], // conversation history for context continuity
|
|
92
127
|
};
|
|
93
128
|
|
|
@@ -190,6 +225,36 @@ const localTools = {
|
|
|
190
225
|
},
|
|
191
226
|
},
|
|
192
227
|
|
|
228
|
+
editFile: {
|
|
229
|
+
description: 'Edit a file by replacing a search string with new content',
|
|
230
|
+
execute: (filePath, search, replace) => {
|
|
231
|
+
try {
|
|
232
|
+
const resolved = path.resolve(filePath);
|
|
233
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
234
|
+
if (!content.includes(search)) return `Error: search string not found in ${resolved}`;
|
|
235
|
+
const updated = content.replace(search, replace);
|
|
236
|
+
fs.writeFileSync(resolved, updated);
|
|
237
|
+
return `Edited: ${resolved} (replaced ${search.length} chars)`;
|
|
238
|
+
} catch (e) { return `Error: ${e.message}`; }
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
deleteFile: {
|
|
243
|
+
description: 'Delete a file or empty directory from this machine',
|
|
244
|
+
execute: (filePath) => {
|
|
245
|
+
try {
|
|
246
|
+
const resolved = path.resolve(filePath);
|
|
247
|
+
const stat = fs.statSync(resolved);
|
|
248
|
+
if (stat.isDirectory()) {
|
|
249
|
+
fs.rmdirSync(resolved);
|
|
250
|
+
} else {
|
|
251
|
+
fs.unlinkSync(resolved);
|
|
252
|
+
}
|
|
253
|
+
return `Deleted: ${resolved}`;
|
|
254
|
+
} catch (e) { return `Error: ${e.message}`; }
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
|
|
193
258
|
systemInfo: {
|
|
194
259
|
description: 'Get system information',
|
|
195
260
|
execute: () => {
|
|
@@ -656,6 +721,8 @@ function openAITools() {
|
|
|
656
721
|
{ name: 'read_file', description: 'Read the contents of a file on the user\'s machine.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Absolute or relative file path' } }, required: ['path'] } },
|
|
657
722
|
{ name: 'write_file', description: 'Write content to a file. Creates parent directories if needed. Use for creating new files, scripts, configs, diagrams (Mermaid, SVG, HTML), code files.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to write' }, content: { type: 'string', description: 'Full content to write to the file' } }, required: ['path', 'content'] } },
|
|
658
723
|
{ name: 'list_files', description: 'List files and directories.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path (default: current dir)' } } } },
|
|
724
|
+
{ name: 'edit_file', description: 'Edit a file by finding and replacing text. Use for targeted edits.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, search: { type: 'string', description: 'Exact text to find' }, replace: { type: 'string', description: 'Replacement text' } }, required: ['path', 'search', 'replace'] } },
|
|
725
|
+
{ name: 'delete_file', description: 'Delete a file or empty directory.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] } },
|
|
659
726
|
{ name: 'system_info', description: 'Get local system information (CPU, RAM, disk, OS, hostname).', parameters: { type: 'object', properties: {} } },
|
|
660
727
|
{ name: 'python_exec', description: 'Execute Python code inline. Use for data analysis, calculations, generating content, processing files, ML tasks.', parameters: { type: 'object', properties: { code: { type: 'string', description: 'Python code to execute' } }, required: ['code'] } },
|
|
661
728
|
{ name: 'python_pip', description: 'Install a Python package via pip.', parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name' } }, required: ['package'] } },
|
|
@@ -837,6 +904,16 @@ async function chat(userMessage, conversationHistory = []) {
|
|
|
837
904
|
description: 'List files and directories.',
|
|
838
905
|
input_schema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path (default: current dir)' } } },
|
|
839
906
|
},
|
|
907
|
+
{
|
|
908
|
+
name: 'edit_file',
|
|
909
|
+
description: 'Edit a file by finding and replacing text. Use for targeted edits without rewriting the whole file.',
|
|
910
|
+
input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, search: { type: 'string', description: 'Exact text to find' }, replace: { type: 'string', description: 'Text to replace it with' } }, required: ['path', 'search', 'replace'] },
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
name: 'delete_file',
|
|
914
|
+
description: 'Delete a file or empty directory from the user\'s machine.',
|
|
915
|
+
input_schema: { type: 'object', properties: { path: { type: 'string', description: 'File or directory path to delete' } }, required: ['path'] },
|
|
916
|
+
},
|
|
840
917
|
{
|
|
841
918
|
name: 'system_info',
|
|
842
919
|
description: 'Get local system information (CPU, RAM, disk, OS, hostname).',
|
|
@@ -961,6 +1038,8 @@ async function executeTool(name, input) {
|
|
|
961
1038
|
case 'read_file': return localTools.readFile.execute(input.path);
|
|
962
1039
|
case 'write_file': return localTools.writeFile.execute(input.path, input.content);
|
|
963
1040
|
case 'list_files': return localTools.listFiles.execute(input.path);
|
|
1041
|
+
case 'edit_file': return localTools.editFile.execute(input.path, input.search, input.replace);
|
|
1042
|
+
case 'delete_file': return localTools.deleteFile.execute(input.path);
|
|
964
1043
|
case 'system_info': return localTools.systemInfo.execute();
|
|
965
1044
|
case 'network_status': return JSON.stringify(await navada.network.ping());
|
|
966
1045
|
case 'lucas_exec': return JSON.stringify(await navada.lucas.exec(input.command));
|
|
@@ -1142,4 +1221,4 @@ async function reportTelemetry(event, data = {}) {
|
|
|
1142
1221
|
}
|
|
1143
1222
|
}
|
|
1144
1223
|
|
|
1145
|
-
module.exports = { IDENTITY, chat, localTools, reportTelemetry, fallbackChat, checkForUpdate, getUpdateInfo, rateTracker, sessionState, addToHistory, getConversationHistory, clearHistory };
|
|
1224
|
+
module.exports = { IDENTITY, chat, localTools, reportTelemetry, fallbackChat, checkForUpdate, getUpdateInfo, rateTracker, sessionState, addToHistory, getConversationHistory, clearHistory, listSubAgents };
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const ui = require('../ui');
|
|
7
|
+
const config = require('../config');
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────
|
|
10
|
+
// CONFIG
|
|
11
|
+
// ─────────────────────────────────────────────
|
|
12
|
+
const NAVADA_DIR = path.join(os.homedir(), '.navada');
|
|
13
|
+
const REPORTS_DIR = path.join(NAVADA_DIR, 'audit-reports');
|
|
14
|
+
const CONFIG_FILE = path.join(NAVADA_DIR, 'config.json');
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────────────────────────
|
|
17
|
+
// SOURCE SCANNER
|
|
18
|
+
// ─────────────────────────────────────────────
|
|
19
|
+
function findInSource(pattern, dir) {
|
|
20
|
+
const searchDirs = dir ? [dir] : ['src', 'lib', 'bin', 'server', 'api', 'routes', '.'];
|
|
21
|
+
const extensions = ['js', 'ts', 'mjs', 'cjs'];
|
|
22
|
+
|
|
23
|
+
for (const d of searchDirs) {
|
|
24
|
+
if (!fs.existsSync(d)) continue;
|
|
25
|
+
const files = walkDir(d, extensions);
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
29
|
+
if (new RegExp(pattern, 'i').test(content)) return { found: true, file };
|
|
30
|
+
} catch { continue; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { found: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walkDir(dir, extensions, files = []) {
|
|
37
|
+
try {
|
|
38
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
39
|
+
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
|
40
|
+
const full = path.join(dir, entry);
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.statSync(full);
|
|
43
|
+
if (stat.isDirectory()) walkDir(full, extensions, files);
|
|
44
|
+
else if (extensions.some(ext => full.endsWith(`.${ext}`))) files.push(full);
|
|
45
|
+
} catch { continue; }
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─────────────────────────────────────────────
|
|
52
|
+
// STATIC CHECKS — current real-world state
|
|
53
|
+
// ─────────────────────────────────────────────
|
|
54
|
+
const CHECKS = {
|
|
55
|
+
|
|
56
|
+
cli: [
|
|
57
|
+
{ id: 'CLI-01', name: 'Entry file has shebang', severity: 'CRITICAL', check: () => {
|
|
58
|
+
for (const f of ['bin/navada.js', 'bin/index.js', 'index.js']) {
|
|
59
|
+
if (fs.existsSync(f)) return fs.readFileSync(f, 'utf8').split('\n')[0].startsWith('#!/usr/bin/env node');
|
|
60
|
+
}
|
|
61
|
+
return { skipped: true, reason: 'No entry file found' };
|
|
62
|
+
}},
|
|
63
|
+
{ id: 'CLI-02', name: 'package.json has bin field', severity: 'CRITICAL', check: () => {
|
|
64
|
+
if (!fs.existsSync('package.json')) return { skipped: true, reason: 'No package.json' };
|
|
65
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
66
|
+
return !!(pkg.bin && Object.keys(pkg.bin).length > 0);
|
|
67
|
+
}},
|
|
68
|
+
{ id: 'CLI-03', name: 'NO_COLOR respected', severity: 'MEDIUM', check: () => findInSource('NO_COLOR').found },
|
|
69
|
+
{ id: 'CLI-04', name: 'Semantic versioning', severity: 'HIGH', check: () => {
|
|
70
|
+
if (!fs.existsSync('package.json')) return false;
|
|
71
|
+
return /^\d+\.\d+\.\d+/.test(JSON.parse(fs.readFileSync('package.json', 'utf8')).version || '');
|
|
72
|
+
}},
|
|
73
|
+
{ id: 'CLI-05', name: 'Config in ~/.navada/', severity: 'HIGH', check: () => findInSource('.navada').found },
|
|
74
|
+
{ id: 'CLI-06', name: 'Non-zero exit on failure', severity: 'HIGH', check: () => findInSource('process.exit\\(1\\)').found },
|
|
75
|
+
{ id: 'CLI-07', name: 'README exists and >500 chars', severity: 'MEDIUM', check: () => {
|
|
76
|
+
if (!fs.existsSync('README.md')) return false;
|
|
77
|
+
return fs.readFileSync('README.md', 'utf8').length > 500;
|
|
78
|
+
}},
|
|
79
|
+
{ id: 'CLI-08', name: 'LICENSE file exists', severity: 'LOW', check: () => fs.existsSync('LICENSE') || fs.existsSync('LICENSE.md') },
|
|
80
|
+
],
|
|
81
|
+
|
|
82
|
+
security: [
|
|
83
|
+
{ id: 'SEC-01', name: 'No hardcoded API keys in source', severity: 'CRITICAL', check: () => {
|
|
84
|
+
for (const p of ['sk-ant-api', 'sk-proj-', 'xai-[a-zA-Z]']) {
|
|
85
|
+
const r = findInSource(p);
|
|
86
|
+
if (r.found) return { pass: false, detail: `Pattern ${p} in ${r.file}` };
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}},
|
|
90
|
+
{ id: 'SEC-02', name: 'No plaintext passwords in source', severity: 'CRITICAL', check: () => !findInSource('password.*=.*["\'][^"\']{8,}["\']').found },
|
|
91
|
+
{ id: 'SEC-03', name: '.gitignore covers .env', severity: 'CRITICAL', check: () => {
|
|
92
|
+
if (!fs.existsSync('.gitignore')) return { pass: false, detail: 'No .gitignore' };
|
|
93
|
+
return fs.readFileSync('.gitignore', 'utf8').includes('.env');
|
|
94
|
+
}},
|
|
95
|
+
{ id: 'SEC-04', name: 'Config file permissions 600', severity: 'HIGH', check: () => {
|
|
96
|
+
if (!fs.existsSync(CONFIG_FILE)) return { skipped: true, reason: 'No config file' };
|
|
97
|
+
try { return (fs.statSync(CONFIG_FILE).mode & 0o777).toString(8) === '600'; }
|
|
98
|
+
catch { return { skipped: true, reason: 'Cannot read permissions' }; }
|
|
99
|
+
}},
|
|
100
|
+
{ id: 'SEC-05', name: 'crypto.randomBytes for key gen', severity: 'CRITICAL', check: () => findInSource('randomBytes').found },
|
|
101
|
+
{ id: 'SEC-06', name: 'IP sanitiser strips internal IPs', severity: 'CRITICAL', check: () => findInSource('100\\\\.\\d|192\\\\.168|sanitise').found },
|
|
102
|
+
{ id: 'SEC-07', name: 'No Tailscale IPs in published npm files', severity: 'CRITICAL', check: () => {
|
|
103
|
+
const r = findInSource('100\\.88\\.118\\.128|100\\.98\\.118\\.33|100\\.77\\.206\\.9|100\\.121\\.187\\.67');
|
|
104
|
+
return !r.found;
|
|
105
|
+
}},
|
|
106
|
+
],
|
|
107
|
+
|
|
108
|
+
api: [
|
|
109
|
+
{ id: 'API-01', name: 'Versioned routes (/v1/)', severity: 'HIGH', check: () => findInSource('/v1/').found },
|
|
110
|
+
{ id: 'API-02', name: 'Rate limiting implemented', severity: 'HIGH', check: () => findInSource('rateLimit|rate.*limit|rateTracker').found },
|
|
111
|
+
{ id: 'API-03', name: 'Health endpoint exists', severity: 'HIGH', check: () => findInSource('/health').found },
|
|
112
|
+
{ id: 'API-04', name: 'CORS configured', severity: 'HIGH', check: () => findInSource('Access-Control-Allow-Origin|cors').found },
|
|
113
|
+
{ id: 'API-05', name: 'Clerk auth on user routes', severity: 'HIGH', check: () => findInSource('requireUser|clerkUser|verifySession').found },
|
|
114
|
+
{ id: 'API-06', name: 'WebSocket server implemented', severity: 'MEDIUM', check: () => findInSource('WebSocketServer|wss|/ws').found },
|
|
115
|
+
],
|
|
116
|
+
|
|
117
|
+
data: [
|
|
118
|
+
{ id: 'DATA-01', name: 'User keys file-based (PG migration pending)', severity: 'MEDIUM', check: () => findInSource('user-keys.json').found },
|
|
119
|
+
{ id: 'DATA-02', name: 'Key validation endpoint exists', severity: 'HIGH', check: () => findInSource('validate-key|validateKey').found },
|
|
120
|
+
{ id: 'DATA-03', name: 'Keys masked in API responses', severity: 'CRITICAL', check: () => findInSource('substring.*12|slice\\(-4\\)').found },
|
|
121
|
+
{ id: 'DATA-04', name: 'User-scoped key queries (userId filter)', severity: 'CRITICAL', check: () => findInSource('userId.*filter|filter.*userId').found },
|
|
122
|
+
{ id: 'DATA-05', name: 'Azure Key Vault sync on key ops', severity: 'HIGH', check: () => findInSource('keyvault|syncKeyToVault|navada-edge-vault').found },
|
|
123
|
+
{ id: 'DATA-06', name: 'Telemetry hashes hostname (no PII)', severity: 'HIGH', check: () => findInSource('createHash.*hostname|sha256.*hostname').found },
|
|
124
|
+
],
|
|
125
|
+
|
|
126
|
+
docker: [
|
|
127
|
+
{ id: 'DOCK-01', name: 'Docker Compose exists', severity: 'HIGH', check: () => fs.existsSync('docker-compose.yml') || fs.existsSync('Dockerfile') },
|
|
128
|
+
{ id: 'DOCK-02', name: 'Restart policy: always', severity: 'HIGH', check: () => {
|
|
129
|
+
if (!fs.existsSync('docker-compose.yml')) return { skipped: true, reason: 'No compose file' };
|
|
130
|
+
return fs.readFileSync('docker-compose.yml', 'utf8').includes('restart: always');
|
|
131
|
+
}},
|
|
132
|
+
{ id: 'DOCK-03', name: 'Healthchecks defined', severity: 'MEDIUM', check: () => {
|
|
133
|
+
if (!fs.existsSync('docker-compose.yml') && !fs.existsSync('Dockerfile')) return { skipped: true, reason: 'No Docker files' };
|
|
134
|
+
const content = fs.existsSync('docker-compose.yml') ? fs.readFileSync('docker-compose.yml', 'utf8') : fs.readFileSync('Dockerfile', 'utf8');
|
|
135
|
+
return content.toLowerCase().includes('healthcheck');
|
|
136
|
+
}},
|
|
137
|
+
{ id: 'DOCK-04', name: 'Non-root user in Dockerfile', severity: 'MEDIUM', check: () => {
|
|
138
|
+
if (!fs.existsSync('Dockerfile')) return { skipped: true, reason: 'No Dockerfile' };
|
|
139
|
+
const content = fs.readFileSync('Dockerfile', 'utf8');
|
|
140
|
+
return content.includes('USER ') && !content.includes('USER root');
|
|
141
|
+
}},
|
|
142
|
+
],
|
|
143
|
+
|
|
144
|
+
compliance: [
|
|
145
|
+
{ id: 'COMP-01', name: 'Privacy Policy exists', severity: 'HIGH', check: () => fs.existsSync('privacy.md') || fs.existsSync('PRIVACY.md') || findInSource('/privacy').found },
|
|
146
|
+
{ id: 'COMP-02', name: 'Terms of Service exists', severity: 'HIGH', check: () => fs.existsSync('terms.md') || fs.existsSync('TERMS.md') || findInSource('/terms').found },
|
|
147
|
+
{ id: 'COMP-03', name: 'CHANGELOG exists', severity: 'MEDIUM', check: () => fs.existsSync('CHANGELOG.md') },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ─────────────────────────────────────────────
|
|
152
|
+
// AI AUDIT PROMPT — matches current deployed state
|
|
153
|
+
// ─────────────────────────────────────────────
|
|
154
|
+
const AI_PROMPT = `You are auditing the NAVADA Edge Platform as deployed TODAY. Be precise — only reference what actually exists.
|
|
155
|
+
|
|
156
|
+
CURRENT STATE (March 2026):
|
|
157
|
+
- CLI: navada-edge-cli v3.4.1 on npm, 75 commands, 10 local tools (shell, files, python, sandbox)
|
|
158
|
+
- SDK: navada-edge-sdk v1.2.0 on npm, zero deps
|
|
159
|
+
- AI Providers: 6 (NAVADA free via OpenAI proxy, Anthropic, OpenAI, Gemini, NVIDIA, HuggingFace)
|
|
160
|
+
- Free tier: Full tool use via GPT-4o-mini proxied through NAVADA dashboard, no API key needed
|
|
161
|
+
- Auth: Clerk on portal (portal.navada-edge-server.uk), API keys (nv_edge_ format, 56 chars)
|
|
162
|
+
- Key storage: File-based JSON (data/user-keys.json) — NOT in a database yet
|
|
163
|
+
- Keys: Generated with crypto.randomBytes(24), stored as plaintext JSON — NOT hashed with bcrypt yet
|
|
164
|
+
- IP sanitiser: Strips 100.x.x.x, 192.168.x.x, 10.x.x.x from user-facing responses
|
|
165
|
+
- Dashboard: Express on :7900, WebSocket on /ws, SSE for metrics
|
|
166
|
+
- Portal: Next.js + Clerk on :3500, via Cloudflare tunnel
|
|
167
|
+
- Infrastructure: 4 Docker nodes (ASUS 9, EC2 7, Oracle 8, HP 2 = 26 containers)
|
|
168
|
+
- Networking: Tailscale VPN mesh, Cloudflare tunnel (18 subdomains), navada-edge Docker network
|
|
169
|
+
- Database: PostgreSQL 17 on HP :5433 (host, not Docker) — used for projects, NOT for user data yet
|
|
170
|
+
- Registry: Private Docker registry at ASUS:5000
|
|
171
|
+
- Portainer: Oracle :9000 managing all 4 nodes via agents
|
|
172
|
+
- Azure Key Vault: navada-edge-vault stores all secrets, user keys synced on create/revoke
|
|
173
|
+
|
|
174
|
+
NOT YET BUILT:
|
|
175
|
+
- Edge Compute MCP (/offload, /sessions, /attach)
|
|
176
|
+
- Agent customisation (agent.md, sub-agents)
|
|
177
|
+
- Azure compute node
|
|
178
|
+
- Billing/metering
|
|
179
|
+
- User data in PostgreSQL (still file-based)
|
|
180
|
+
- bcrypt key hashing
|
|
181
|
+
- GDPR endpoints (deletion, export)
|
|
182
|
+
- OpenAPI spec
|
|
183
|
+
- Privacy Policy / Terms of Service
|
|
184
|
+
|
|
185
|
+
Based on the static check results, audit:
|
|
186
|
+
1. SECURITY: Key handling, auth, IP exposure, file permissions, secret management
|
|
187
|
+
2. API DESIGN: Versioning, rate limiting, error handling, CORS, WebSocket auth
|
|
188
|
+
3. DATA: User isolation, key masking, storage security, vault sync
|
|
189
|
+
4. DOCKER: Compose management, healthchecks, restart policies, network isolation
|
|
190
|
+
5. GAPS: What must be fixed before first paying customer (prioritised)
|
|
191
|
+
|
|
192
|
+
For each finding: [PASS/FAIL/PARTIAL] — ID — Item — Severity — One-line fix
|
|
193
|
+
End with: Top 5 priorities before first customer (numbered, actionable).`;
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────
|
|
196
|
+
// RUN CHECKS
|
|
197
|
+
// ─────────────────────────────────────────────
|
|
198
|
+
function runCheck(check) {
|
|
199
|
+
try {
|
|
200
|
+
const r = check.check();
|
|
201
|
+
if (r === true) return 'PASS';
|
|
202
|
+
if (r === false) return 'FAIL';
|
|
203
|
+
if (r && r.skipped) return 'SKIP';
|
|
204
|
+
if (r && r.pass === false) return 'FAIL';
|
|
205
|
+
return 'PASS';
|
|
206
|
+
} catch { return 'ERROR'; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runAIAudit(staticSummary) {
|
|
210
|
+
const apiKey = config.get('anthropicKey') || process.env.ANTHROPIC_API_KEY;
|
|
211
|
+
if (!apiKey) return null;
|
|
212
|
+
|
|
213
|
+
const https = require('https');
|
|
214
|
+
const body = JSON.stringify({
|
|
215
|
+
model: 'claude-sonnet-4-20250514',
|
|
216
|
+
max_tokens: 4000,
|
|
217
|
+
messages: [{ role: 'user', content: `${AI_PROMPT}\n\nSTATIC RESULTS:\n${staticSummary}` }],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
const req = https.request('https://api.anthropic.com/v1/messages', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
224
|
+
timeout: 60000,
|
|
225
|
+
}, (res) => {
|
|
226
|
+
let data = '';
|
|
227
|
+
res.on('data', c => data += c);
|
|
228
|
+
res.on('end', () => {
|
|
229
|
+
try {
|
|
230
|
+
const d = JSON.parse(data);
|
|
231
|
+
resolve(d.content?.[0]?.text || d.error?.message || 'No response');
|
|
232
|
+
} catch { resolve('Failed to parse AI response'); }
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
req.on('error', (e) => resolve(`AI audit error: ${e.message}`));
|
|
236
|
+
req.on('timeout', () => { req.destroy(); resolve('AI audit timed out'); });
|
|
237
|
+
req.write(body);
|
|
238
|
+
req.end();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─────────────────────────────────────────────
|
|
243
|
+
// CLI COMMAND REGISTRATION
|
|
244
|
+
// ─────────────────────────────────────────────
|
|
245
|
+
module.exports = function(reg) {
|
|
246
|
+
|
|
247
|
+
reg('audit', 'Enterprise security and compliance audit', async (args) => {
|
|
248
|
+
const section = args[0];
|
|
249
|
+
const saveFlag = args.includes('--save');
|
|
250
|
+
const jsonFlag = args.includes('--json');
|
|
251
|
+
|
|
252
|
+
if (section === 'help') {
|
|
253
|
+
console.log(ui.header('NAVADA ENTERPRISE AUDIT'));
|
|
254
|
+
console.log(ui.cmd('audit', 'Full audit (all sections)'));
|
|
255
|
+
console.log(ui.cmd('audit cli', 'CLI standards checks'));
|
|
256
|
+
console.log(ui.cmd('audit security', 'Security checks'));
|
|
257
|
+
console.log(ui.cmd('audit api', 'API design checks'));
|
|
258
|
+
console.log(ui.cmd('audit data', 'Data management checks'));
|
|
259
|
+
console.log(ui.cmd('audit docker', 'Docker/infrastructure checks'));
|
|
260
|
+
console.log(ui.cmd('audit compliance', 'Compliance checks'));
|
|
261
|
+
console.log('');
|
|
262
|
+
console.log(ui.dim('Flags: --save (save report) --json (machine output)'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const startTime = Date.now();
|
|
267
|
+
if (!jsonFlag) console.log(ui.header('NAVADA ENTERPRISE AUDIT'));
|
|
268
|
+
|
|
269
|
+
// Determine sections to run
|
|
270
|
+
const validSections = Object.keys(CHECKS);
|
|
271
|
+
const sections = section && validSections.includes(section) ? { [section]: CHECKS[section] } : CHECKS;
|
|
272
|
+
|
|
273
|
+
if (section && !validSections.includes(section) && section !== '--save' && section !== '--json') {
|
|
274
|
+
console.log(ui.error(`Unknown section: ${section}`));
|
|
275
|
+
console.log(ui.dim(`Available: ${validSections.join(', ')}`));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Run static checks
|
|
280
|
+
const results = {};
|
|
281
|
+
let pass = 0, fail = 0, skip = 0;
|
|
282
|
+
const criticals = [];
|
|
283
|
+
|
|
284
|
+
for (const [name, checks] of Object.entries(sections)) {
|
|
285
|
+
results[name] = [];
|
|
286
|
+
if (!jsonFlag) console.log(`\n ${ui.dim(name.toUpperCase())}`);
|
|
287
|
+
|
|
288
|
+
for (const check of checks) {
|
|
289
|
+
const status = runCheck(check);
|
|
290
|
+
results[name].push({ id: check.id, name: check.name, severity: check.severity, status });
|
|
291
|
+
|
|
292
|
+
if (status === 'PASS') pass++;
|
|
293
|
+
else if (status === 'FAIL') { fail++; if (check.severity === 'CRITICAL') criticals.push(check); }
|
|
294
|
+
else skip++;
|
|
295
|
+
|
|
296
|
+
if (!jsonFlag) {
|
|
297
|
+
const icon = status === 'PASS' ? '\x1b[32mPASS\x1b[0m' : status === 'FAIL' ? '\x1b[31mFAIL\x1b[0m' : '\x1b[2mSKIP\x1b[0m';
|
|
298
|
+
const sev = check.severity === 'CRITICAL' ? '\x1b[31m' : check.severity === 'HIGH' ? '\x1b[33m' : '\x1b[2m';
|
|
299
|
+
console.log(` ${icon} ${sev}${check.severity.padEnd(8)}\x1b[0m ${check.id} ${check.name}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Summary
|
|
305
|
+
const total = pass + fail + skip;
|
|
306
|
+
const rate = Math.round((pass / (total - skip)) * 100);
|
|
307
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
308
|
+
|
|
309
|
+
if (!jsonFlag) {
|
|
310
|
+
console.log(ui.header('SUMMARY'));
|
|
311
|
+
console.log(ui.label('PASS', String(pass)));
|
|
312
|
+
console.log(ui.label('FAIL', String(fail)));
|
|
313
|
+
console.log(ui.label('SKIP', String(skip)));
|
|
314
|
+
console.log(ui.label('Pass rate', `${rate}%`));
|
|
315
|
+
console.log(ui.label('Time', `${elapsed}s`));
|
|
316
|
+
|
|
317
|
+
if (criticals.length > 0) {
|
|
318
|
+
console.log('');
|
|
319
|
+
console.log(ui.error('CRITICAL FAILURES:'));
|
|
320
|
+
for (const c of criticals) console.log(ui.error(` ${c.id} — ${c.name}`));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// AI audit (if no specific section and API key available)
|
|
325
|
+
let aiResult = null;
|
|
326
|
+
if (!section || section === '--save') {
|
|
327
|
+
const summary = Object.entries(results).map(([s, checks]) =>
|
|
328
|
+
`[${s.toUpperCase()}]\n` + checks.map(c => ` ${c.status.padEnd(5)} ${c.id} — ${c.name}`).join('\n')
|
|
329
|
+
).join('\n');
|
|
330
|
+
|
|
331
|
+
if (!jsonFlag) console.log(ui.dim('\n Running AI audit...'));
|
|
332
|
+
aiResult = await runAIAudit(summary);
|
|
333
|
+
if (aiResult && !jsonFlag) {
|
|
334
|
+
console.log(ui.header('AI AUDIT FINDINGS'));
|
|
335
|
+
console.log(` ${aiResult}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Save report
|
|
340
|
+
if (saveFlag) {
|
|
341
|
+
const report = { meta: { date: new Date().toISOString(), elapsed, cwd: process.cwd() }, summary: { total, pass, fail, skip, rate }, results, aiAudit: aiResult };
|
|
342
|
+
try {
|
|
343
|
+
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
|
344
|
+
const file = path.join(REPORTS_DIR, `audit-${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
|
|
345
|
+
fs.writeFileSync(file, JSON.stringify(report, null, 2));
|
|
346
|
+
if (!jsonFlag) console.log(ui.success(`Report saved: ${file}`));
|
|
347
|
+
} catch (e) { if (!jsonFlag) console.log(ui.warn(`Could not save: ${e.message}`)); }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (jsonFlag) {
|
|
351
|
+
console.log(JSON.stringify({ summary: { total, pass, fail, skip, rate }, results, aiAudit: aiResult }, null, 2));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
}, { category: 'SYSTEM', subs: ['cli', 'security', 'api', 'data', 'docker', 'compliance', 'help'] });
|
|
355
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const navada = require('navada-edge-sdk');
|
|
4
|
+
const ui = require('../ui');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
const COMPUTE_ENDPOINT = 'https://edge-compute.navada-edge-server.uk';
|
|
10
|
+
|
|
11
|
+
function getEdgeKey() {
|
|
12
|
+
return config.get('edgeKey') || '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function request(method, path, body) {
|
|
16
|
+
const key = getEdgeKey();
|
|
17
|
+
if (!key) return Promise.reject(new Error('Not connected. Run /edge login <key> first.'));
|
|
18
|
+
|
|
19
|
+
const url = new URL(COMPUTE_ENDPOINT + path);
|
|
20
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
21
|
+
const payload = body ? JSON.stringify(body) : '';
|
|
22
|
+
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const req = transport.request(url, {
|
|
25
|
+
method,
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
'x-api-key': key,
|
|
29
|
+
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
30
|
+
},
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
}, (res) => {
|
|
33
|
+
let data = '';
|
|
34
|
+
res.on('data', c => data += c);
|
|
35
|
+
res.on('end', () => {
|
|
36
|
+
try { resolve({ status: res.statusCode, data: JSON.parse(data) }); }
|
|
37
|
+
catch { resolve({ status: res.statusCode, data }); }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
42
|
+
if (payload) req.write(payload);
|
|
43
|
+
req.end();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = function(reg) {
|
|
48
|
+
|
|
49
|
+
// ── /offload ── Send a task to the cloud
|
|
50
|
+
reg('offload', 'Run a task on the Edge Network (24/7 cloud)', async (args) => {
|
|
51
|
+
if (!args.length) {
|
|
52
|
+
console.log(ui.header('EDGE COMPUTE — OFFLOAD'));
|
|
53
|
+
console.log(ui.cmd('offload <command>', 'Run a shell command on EC2'));
|
|
54
|
+
console.log(ui.cmd('offload --python <code>', 'Run Python code on EC2'));
|
|
55
|
+
console.log(ui.cmd('offload --js <code>', 'Run JavaScript on EC2'));
|
|
56
|
+
console.log(ui.cmd('offload --name <name> <cmd>', 'Name the task'));
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(ui.dim('Examples:'));
|
|
59
|
+
console.log(ui.dim(' /offload npm test'));
|
|
60
|
+
console.log(ui.dim(' /offload --python "import time; time.sleep(60); print(\'done\')"'));
|
|
61
|
+
console.log(ui.dim(' /offload --name "nightly-backup" tar czf backup.tar.gz /data'));
|
|
62
|
+
console.log('');
|
|
63
|
+
console.log(ui.dim('Requires Edge Network connection: /edge login <key>'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isPython = args.includes('--python');
|
|
68
|
+
const isJs = args.includes('--js');
|
|
69
|
+
const nameIdx = args.indexOf('--name');
|
|
70
|
+
let name = 'task';
|
|
71
|
+
let filteredArgs = [...args];
|
|
72
|
+
|
|
73
|
+
if (nameIdx >= 0) {
|
|
74
|
+
name = args[nameIdx + 1] || 'task';
|
|
75
|
+
filteredArgs.splice(nameIdx, 2);
|
|
76
|
+
}
|
|
77
|
+
filteredArgs = filteredArgs.filter(a => a !== '--python' && a !== '--js');
|
|
78
|
+
|
|
79
|
+
const body = {};
|
|
80
|
+
if (isPython) {
|
|
81
|
+
body.code = filteredArgs.join(' ');
|
|
82
|
+
body.language = 'python';
|
|
83
|
+
} else if (isJs) {
|
|
84
|
+
body.code = filteredArgs.join(' ');
|
|
85
|
+
body.language = 'javascript';
|
|
86
|
+
} else {
|
|
87
|
+
body.command = filteredArgs.join(' ');
|
|
88
|
+
}
|
|
89
|
+
body.name = name;
|
|
90
|
+
|
|
91
|
+
const ora = require('ora');
|
|
92
|
+
const spinner = ora({ text: ' Offloading to Edge Network...', color: 'white' }).start();
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const r = await request('POST', '/offload', body);
|
|
96
|
+
spinner.stop();
|
|
97
|
+
|
|
98
|
+
if (r.status === 201) {
|
|
99
|
+
console.log(ui.success(`Task offloaded: ${r.data.session.id}`));
|
|
100
|
+
console.log(ui.label('Session', r.data.session.id));
|
|
101
|
+
console.log(ui.label('Task', r.data.session.task));
|
|
102
|
+
console.log(ui.label('Status', r.data.session.status));
|
|
103
|
+
console.log('');
|
|
104
|
+
console.log(ui.dim(`Track: /sessions`));
|
|
105
|
+
console.log(ui.dim(`Output: /attach ${r.data.session.id}`));
|
|
106
|
+
} else if (r.status === 401) {
|
|
107
|
+
console.log(ui.error('Invalid API key. Run /edge login <key>'));
|
|
108
|
+
} else if (r.status === 429) {
|
|
109
|
+
console.log(ui.warn(r.data.error || 'Concurrent task limit reached'));
|
|
110
|
+
} else {
|
|
111
|
+
console.log(ui.error(r.data.error || `HTTP ${r.status}`));
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
spinner.stop();
|
|
115
|
+
console.log(ui.error(`Edge Compute unreachable: ${e.message}`));
|
|
116
|
+
console.log(ui.dim('The Edge Network may be offline. Try /doctor'));
|
|
117
|
+
}
|
|
118
|
+
}, { category: 'EDGE' });
|
|
119
|
+
|
|
120
|
+
// ── /sessions ── List cloud sessions
|
|
121
|
+
reg('sessions', 'View Edge Network task sessions', async () => {
|
|
122
|
+
const ora = require('ora');
|
|
123
|
+
const spinner = ora({ text: ' Loading sessions...', color: 'white' }).start();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const r = await request('GET', '/sessions');
|
|
127
|
+
spinner.stop();
|
|
128
|
+
|
|
129
|
+
if (r.status === 401) { console.log(ui.error('Not connected. /edge login <key>')); return; }
|
|
130
|
+
if (r.status !== 200) { console.log(ui.error(r.data.error || `HTTP ${r.status}`)); return; }
|
|
131
|
+
|
|
132
|
+
console.log(ui.header('EDGE SESSIONS'));
|
|
133
|
+
const sessions = r.data.sessions || [];
|
|
134
|
+
|
|
135
|
+
if (sessions.length === 0) {
|
|
136
|
+
console.log(ui.dim('No sessions yet. /offload <command> to start one.'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const s of sessions) {
|
|
141
|
+
const statusColor = s.status === 'completed' ? '\x1b[32m' : s.status === 'running' ? '\x1b[33m' : '\x1b[31m';
|
|
142
|
+
const statusLabel = `${statusColor}${s.status.toUpperCase()}\x1b[0m`;
|
|
143
|
+
const age = s.completedAt ? timeSince(s.completedAt) : timeSince(s.createdAt);
|
|
144
|
+
console.log(` ${statusLabel} ${s.id.slice(0, 16)} ${(s.task || '').padEnd(20)} ${age}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log(ui.label('Total', String(r.data.total)));
|
|
149
|
+
console.log(ui.label('Limits', `${r.data.limits.concurrent} concurrent, ${Math.round(r.data.limits.maxRuntime / 60000)}min max`));
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log(ui.dim('Details: /attach <session-id>'));
|
|
152
|
+
} catch (e) {
|
|
153
|
+
spinner.stop();
|
|
154
|
+
console.log(ui.error(`Edge Compute unreachable: ${e.message}`));
|
|
155
|
+
}
|
|
156
|
+
}, { category: 'EDGE' });
|
|
157
|
+
|
|
158
|
+
// ── /attach ── Stream session output
|
|
159
|
+
reg('attach', 'Attach to a running Edge session (stream output)', async (args) => {
|
|
160
|
+
const id = args[0];
|
|
161
|
+
if (!id) {
|
|
162
|
+
console.log(ui.dim('Usage: /attach <session-id>'));
|
|
163
|
+
console.log(ui.dim('List sessions: /sessions'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// If short ID, try to match
|
|
168
|
+
const sessionId = id.startsWith('ses_') ? id : `ses_${id}`;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const r = await request('GET', `/session/${sessionId}`);
|
|
172
|
+
if (r.status === 404) { console.log(ui.error('Session not found')); return; }
|
|
173
|
+
if (r.status === 401) { console.log(ui.error('Not connected. /edge login <key>')); return; }
|
|
174
|
+
|
|
175
|
+
const s = r.data;
|
|
176
|
+
console.log(ui.header(`SESSION ${s.id}`));
|
|
177
|
+
console.log(ui.label('Task', s.task?.name || s.task?.command?.slice(0, 60) || '?'));
|
|
178
|
+
console.log(ui.label('Status', s.status));
|
|
179
|
+
console.log(ui.label('Created', s.createdAt));
|
|
180
|
+
if (s.completedAt) console.log(ui.label('Completed', s.completedAt));
|
|
181
|
+
if (s.exitCode !== null) console.log(ui.label('Exit code', String(s.exitCode)));
|
|
182
|
+
if (s.error) console.log(ui.label('Error', s.error));
|
|
183
|
+
|
|
184
|
+
if (s.output) {
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(ui.dim('── OUTPUT ──'));
|
|
187
|
+
console.log(s.output);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (s.status === 'running') {
|
|
191
|
+
console.log('');
|
|
192
|
+
console.log(ui.dim('Session is running. Output will update on refresh.'));
|
|
193
|
+
console.log(ui.dim('Kill: /kill ' + s.id));
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.log(ui.error(`Edge Compute unreachable: ${e.message}`));
|
|
197
|
+
}
|
|
198
|
+
}, { category: 'EDGE' });
|
|
199
|
+
|
|
200
|
+
// ── /kill ── Kill a running session
|
|
201
|
+
reg('kill', 'Kill a running Edge session', async (args) => {
|
|
202
|
+
const id = args[0];
|
|
203
|
+
if (!id) { console.log(ui.dim('Usage: /kill <session-id>')); return; }
|
|
204
|
+
const sessionId = id.startsWith('ses_') ? id : `ses_${id}`;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const r = await request('DELETE', `/session/${sessionId}`);
|
|
208
|
+
if (r.status === 200) {
|
|
209
|
+
console.log(ui.success(`Session ${sessionId} killed`));
|
|
210
|
+
} else {
|
|
211
|
+
console.log(ui.error(r.data.error || `HTTP ${r.status}`));
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.log(ui.error(e.message));
|
|
215
|
+
}
|
|
216
|
+
}, { category: 'EDGE' });
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function timeSince(iso) {
|
|
220
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
221
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s ago`;
|
|
222
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m ago`;
|
|
223
|
+
if (ms < 86400000) return `${Math.round(ms / 3600000)}h ago`;
|
|
224
|
+
return `${Math.round(ms / 86400000)}d ago`;
|
|
225
|
+
}
|
package/lib/commands/edge.js
CHANGED
|
@@ -9,6 +9,8 @@ const VALIDATE_ENDPOINTS = [
|
|
|
9
9
|
'https://api.navada-edge-server.uk/api/v1/public/validate-key',
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
+
const { sessionState, listSubAgents } = require('../agent');
|
|
13
|
+
|
|
12
14
|
module.exports = function(reg) {
|
|
13
15
|
|
|
14
16
|
// /onboard — open portal in browser
|
|
@@ -51,9 +53,10 @@ module.exports = function(reg) {
|
|
|
51
53
|
console.log(ui.cmd('edge status', 'Check your Edge Network connection'));
|
|
52
54
|
console.log(ui.cmd('edge logout', 'Disconnect from Edge Network'));
|
|
53
55
|
console.log(ui.cmd('edge tier', 'Show your current tier and limits'));
|
|
56
|
+
console.log(ui.cmd('edge setup', 'Create agent.md and sub-agents directory'));
|
|
54
57
|
console.log(ui.cmd('onboard', 'Create account and get API key'));
|
|
55
58
|
console.log('');
|
|
56
|
-
console.log(ui.dim('Get started: /onboard'));
|
|
59
|
+
console.log(ui.dim('Get started: /onboard | Customise: /edge setup'));
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -179,8 +182,133 @@ module.exports = function(reg) {
|
|
|
179
182
|
return;
|
|
180
183
|
}
|
|
181
184
|
|
|
185
|
+
// /edge setup — create agent.md and agents/ directory
|
|
186
|
+
if (sub === 'setup') {
|
|
187
|
+
const fs = require('fs');
|
|
188
|
+
const path = require('path');
|
|
189
|
+
const agentDir = path.join(config.CONFIG_DIR, 'agents');
|
|
190
|
+
const agentMd = path.join(config.CONFIG_DIR, 'agent.md');
|
|
191
|
+
|
|
192
|
+
console.log(ui.header('NAVADA EDGE — AGENT SETUP'));
|
|
193
|
+
|
|
194
|
+
// Create directories
|
|
195
|
+
if (!fs.existsSync(config.CONFIG_DIR)) fs.mkdirSync(config.CONFIG_DIR, { recursive: true });
|
|
196
|
+
if (!fs.existsSync(agentDir)) fs.mkdirSync(agentDir, { recursive: true });
|
|
197
|
+
|
|
198
|
+
// Create agent.md if it doesn't exist
|
|
199
|
+
if (!fs.existsSync(agentMd)) {
|
|
200
|
+
fs.writeFileSync(agentMd, `# My NAVADA Agent
|
|
201
|
+
|
|
202
|
+
## About Me
|
|
203
|
+
<!-- Tell the agent who you are, what you do, what stack you use -->
|
|
204
|
+
I am a developer working on...
|
|
205
|
+
|
|
206
|
+
## My Infrastructure
|
|
207
|
+
<!-- Describe your servers, services, databases -->
|
|
208
|
+
- ...
|
|
209
|
+
|
|
210
|
+
## Preferences
|
|
211
|
+
<!-- How should the agent behave? What tone? What conventions? -->
|
|
212
|
+
- Be concise and technical
|
|
213
|
+
- Use TypeScript for new code
|
|
214
|
+
- Always explain changes before making them
|
|
215
|
+
|
|
216
|
+
## Automation Rules
|
|
217
|
+
<!-- What should the agent do automatically? -->
|
|
218
|
+
- ...
|
|
219
|
+
`);
|
|
220
|
+
console.log(ui.success('Created ~/.navada/agent.md'));
|
|
221
|
+
console.log(ui.dim(' Edit this file to customise your agent\'s personality and context.'));
|
|
222
|
+
} else {
|
|
223
|
+
console.log(ui.dim(' agent.md already exists'));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Create example sub-agent
|
|
227
|
+
const exampleAgent = path.join(agentDir, 'deploy-bot.md');
|
|
228
|
+
if (!fs.existsSync(exampleAgent)) {
|
|
229
|
+
fs.writeFileSync(exampleAgent, `You are a deployment specialist agent. When the user asks you to deploy, you:
|
|
230
|
+
1. Check the current git status
|
|
231
|
+
2. Run tests if a test script exists
|
|
232
|
+
3. Build the Docker image
|
|
233
|
+
4. Push to the registry at 100.88.118.128:5000
|
|
234
|
+
5. Deploy to the target node via SSH
|
|
235
|
+
|
|
236
|
+
Be methodical. Confirm each step before proceeding. Report any failures immediately.
|
|
237
|
+
`);
|
|
238
|
+
console.log(ui.success('Created ~/.navada/agents/deploy-bot.md (example)'));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log('');
|
|
242
|
+
console.log(ui.label('Agent config', agentMd));
|
|
243
|
+
console.log(ui.label('Sub-agents', agentDir));
|
|
244
|
+
console.log('');
|
|
245
|
+
console.log(ui.dim('Your agent.md is loaded every time you chat.'));
|
|
246
|
+
console.log(ui.dim('Sub-agents: /agent list | /agent use deploy-bot'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
182
250
|
console.log(ui.dim('Unknown subcommand. Try /edge help'));
|
|
183
251
|
|
|
184
|
-
}, { category: 'EDGE', subs: ['login', 'status', 'logout', 'tier', 'help'] });
|
|
252
|
+
}, { category: 'EDGE', subs: ['login', 'status', 'logout', 'tier', 'setup', 'help'] });
|
|
253
|
+
|
|
254
|
+
// ── /agent ── Manage sub-agents
|
|
255
|
+
reg('agent', 'Manage NAVADA sub-agents', (args) => {
|
|
256
|
+
const sub = args[0];
|
|
257
|
+
const fs = require('fs');
|
|
258
|
+
const path = require('path');
|
|
259
|
+
|
|
260
|
+
if (!sub || sub === 'list') {
|
|
261
|
+
const agents = listSubAgents();
|
|
262
|
+
console.log(ui.header('SUB-AGENTS'));
|
|
263
|
+
if (agents.length === 0) {
|
|
264
|
+
console.log(ui.dim('No sub-agents. Create one: /edge setup'));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
for (const a of agents) {
|
|
268
|
+
const active = sessionState.subAgent === a ? ' \x1b[32m(active)\x1b[0m' : '';
|
|
269
|
+
console.log(ui.label(a, active));
|
|
270
|
+
}
|
|
271
|
+
console.log('');
|
|
272
|
+
console.log(ui.dim('Use: /agent use <name> | Off: /agent off'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (sub === 'use' && args[1]) {
|
|
277
|
+
const name = args[1];
|
|
278
|
+
const agentFile = path.join(config.CONFIG_DIR, 'agents', `${name}.md`);
|
|
279
|
+
if (!fs.existsSync(agentFile)) {
|
|
280
|
+
console.log(ui.error(`Sub-agent not found: ${name}`));
|
|
281
|
+
console.log(ui.dim('Available: ' + (listSubAgents().join(', ') || 'none')));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
sessionState.subAgent = name;
|
|
285
|
+
console.log(ui.success(`Sub-agent activated: ${name}`));
|
|
286
|
+
console.log(ui.dim('The agent will now use this persona. /agent off to reset.'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (sub === 'off' || sub === 'reset') {
|
|
291
|
+
sessionState.subAgent = null;
|
|
292
|
+
console.log(ui.success('Sub-agent deactivated. Using default NAVADA agent.'));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (sub === 'show' && args[1]) {
|
|
297
|
+
const agentFile = path.join(config.CONFIG_DIR, 'agents', `${args[1]}.md`);
|
|
298
|
+
if (!fs.existsSync(agentFile)) { console.log(ui.error('Not found')); return; }
|
|
299
|
+
console.log(ui.header(`AGENT: ${args[1]}`));
|
|
300
|
+
console.log(fs.readFileSync(agentFile, 'utf-8'));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
console.log(ui.header('AGENT COMMANDS'));
|
|
305
|
+
console.log(ui.cmd('agent list', 'List all sub-agents'));
|
|
306
|
+
console.log(ui.cmd('agent use <name>', 'Activate a sub-agent'));
|
|
307
|
+
console.log(ui.cmd('agent off', 'Deactivate sub-agent'));
|
|
308
|
+
console.log(ui.cmd('agent show <name>', 'View sub-agent prompt'));
|
|
309
|
+
console.log('');
|
|
310
|
+
console.log(ui.dim('Create agents: /edge setup'));
|
|
311
|
+
console.log(ui.dim('Agents live in: ~/.navada/agents/<name>.md'));
|
|
312
|
+
}, { category: 'EDGE', subs: ['list', 'use', 'off', 'show', 'reset'] });
|
|
185
313
|
|
|
186
314
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ui = require('../ui');
|
|
6
|
+
|
|
7
|
+
module.exports = function(reg) {
|
|
8
|
+
|
|
9
|
+
// ── /read <path> ── Read a file
|
|
10
|
+
reg('read', 'Read a file from your machine', (args) => {
|
|
11
|
+
if (!args[0]) { console.log(ui.dim('Usage: /read <file-path>')); return; }
|
|
12
|
+
const filePath = path.resolve(args.join(' '));
|
|
13
|
+
try {
|
|
14
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
console.log(ui.header(`FILE: ${filePath}`));
|
|
17
|
+
console.log(ui.dim(`${lines.length} lines, ${Buffer.byteLength(content)} bytes`));
|
|
18
|
+
console.log('');
|
|
19
|
+
// Show with line numbers, cap at 200 lines
|
|
20
|
+
const show = lines.slice(0, 200);
|
|
21
|
+
show.forEach((line, i) => console.log(` ${String(i + 1).padStart(4)} ${line}`));
|
|
22
|
+
if (lines.length > 200) console.log(ui.dim(` ... ${lines.length - 200} more lines`));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.log(ui.error(e.code === 'ENOENT' ? `File not found: ${filePath}` : e.message));
|
|
25
|
+
}
|
|
26
|
+
}, { category: 'FILES', aliases: ['cat'] });
|
|
27
|
+
|
|
28
|
+
// ── /write <path> <content> ── Write/create a file
|
|
29
|
+
reg('write', 'Write content to a file (creates dirs if needed)', (args) => {
|
|
30
|
+
if (args.length < 2) {
|
|
31
|
+
console.log(ui.dim('Usage: /write <file-path> <content>'));
|
|
32
|
+
console.log(ui.dim(' /write hello.txt Hello World'));
|
|
33
|
+
console.log(ui.dim(' /write src/config.json {"key":"value"}'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const filePath = path.resolve(args[0]);
|
|
37
|
+
const content = args.slice(1).join(' ');
|
|
38
|
+
try {
|
|
39
|
+
const dir = path.dirname(filePath);
|
|
40
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
fs.writeFileSync(filePath, content);
|
|
42
|
+
console.log(ui.success(`Written: ${filePath} (${Buffer.byteLength(content)} bytes)`));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.log(ui.error(e.message));
|
|
45
|
+
}
|
|
46
|
+
}, { category: 'FILES' });
|
|
47
|
+
|
|
48
|
+
// ── /edit <path> <search> -> <replace> ── Find and replace in a file
|
|
49
|
+
reg('edit', 'Edit a file (find and replace)', (args) => {
|
|
50
|
+
const raw = args.join(' ');
|
|
51
|
+
const sepIdx = raw.indexOf(' -> ');
|
|
52
|
+
if (!args[0] || sepIdx === -1) {
|
|
53
|
+
console.log(ui.dim('Usage: /edit <file-path> <search-text> -> <replace-text>'));
|
|
54
|
+
console.log(ui.dim(' /edit config.json "port": 3000 -> "port": 8080'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// First arg is the file path, rest is search -> replace
|
|
58
|
+
const filePath = path.resolve(args[0]);
|
|
59
|
+
const afterPath = raw.slice(raw.indexOf(args[0]) + args[0].length + 1);
|
|
60
|
+
const arrowIdx = afterPath.indexOf(' -> ');
|
|
61
|
+
if (arrowIdx === -1) {
|
|
62
|
+
console.log(ui.dim('Use " -> " to separate search and replace text'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const search = afterPath.slice(0, arrowIdx);
|
|
66
|
+
const replace = afterPath.slice(arrowIdx + 4);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
70
|
+
if (!content.includes(search)) {
|
|
71
|
+
console.log(ui.error('Search text not found in file'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const updated = content.replace(search, replace);
|
|
75
|
+
fs.writeFileSync(filePath, updated);
|
|
76
|
+
console.log(ui.success(`Edited: ${filePath}`));
|
|
77
|
+
console.log(ui.dim(` "${search.slice(0, 40)}${search.length > 40 ? '...' : ''}" → "${replace.slice(0, 40)}${replace.length > 40 ? '...' : ''}"`));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.log(ui.error(e.code === 'ENOENT' ? `File not found: ${filePath}` : e.message));
|
|
80
|
+
}
|
|
81
|
+
}, { category: 'FILES' });
|
|
82
|
+
|
|
83
|
+
// ── /delete <path> ── Delete a file
|
|
84
|
+
reg('delete', 'Delete a file or empty directory', (args) => {
|
|
85
|
+
if (!args[0]) { console.log(ui.dim('Usage: /delete <file-path>')); return; }
|
|
86
|
+
const filePath = path.resolve(args.join(' '));
|
|
87
|
+
try {
|
|
88
|
+
const stat = fs.statSync(filePath);
|
|
89
|
+
if (stat.isDirectory()) {
|
|
90
|
+
fs.rmdirSync(filePath);
|
|
91
|
+
console.log(ui.success(`Deleted directory: ${filePath}`));
|
|
92
|
+
} else {
|
|
93
|
+
fs.unlinkSync(filePath);
|
|
94
|
+
console.log(ui.success(`Deleted: ${filePath} (${stat.size} bytes)`));
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e.code === 'ENOENT') console.log(ui.error(`Not found: ${filePath}`));
|
|
98
|
+
else if (e.code === 'ENOTEMPTY') console.log(ui.error('Directory not empty. Use /run rm -rf <path> for recursive delete.'));
|
|
99
|
+
else console.log(ui.error(e.message));
|
|
100
|
+
}
|
|
101
|
+
}, { category: 'FILES', aliases: ['rm'] });
|
|
102
|
+
|
|
103
|
+
// ── /ls [path] ── List files
|
|
104
|
+
reg('ls', 'List files and directories', (args) => {
|
|
105
|
+
const dir = path.resolve(args.join(' ') || '.');
|
|
106
|
+
try {
|
|
107
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
108
|
+
console.log(ui.header(`DIR: ${dir}`));
|
|
109
|
+
if (items.length === 0) { console.log(ui.dim(' (empty)')); return; }
|
|
110
|
+
|
|
111
|
+
const dirs = items.filter(i => i.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
112
|
+
const files = items.filter(i => !i.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
113
|
+
|
|
114
|
+
for (const d of dirs) console.log(` \x1b[34md\x1b[0m ${d.name}/`);
|
|
115
|
+
for (const f of files) {
|
|
116
|
+
try {
|
|
117
|
+
const stat = fs.statSync(path.join(dir, f.name));
|
|
118
|
+
const size = stat.size < 1024 ? `${stat.size}B` : stat.size < 1048576 ? `${(stat.size / 1024).toFixed(1)}K` : `${(stat.size / 1048576).toFixed(1)}M`;
|
|
119
|
+
console.log(` f ${f.name.padEnd(40)} ${size}`);
|
|
120
|
+
} catch {
|
|
121
|
+
console.log(` f ${f.name}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log(ui.dim(` ${dirs.length} dirs, ${files.length} files`));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.log(ui.error(e.code === 'ENOENT' ? `Not found: ${dir}` : e.message));
|
|
128
|
+
}
|
|
129
|
+
}, { category: 'FILES', aliases: ['dir'] });
|
|
130
|
+
|
|
131
|
+
// ── /mkdir <path> ── Create directory
|
|
132
|
+
reg('mkdir', 'Create a directory (including parents)', (args) => {
|
|
133
|
+
if (!args[0]) { console.log(ui.dim('Usage: /mkdir <dir-path>')); return; }
|
|
134
|
+
const dirPath = path.resolve(args.join(' '));
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
137
|
+
console.log(ui.success(`Created: ${dirPath}`));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.log(ui.error(e.message));
|
|
140
|
+
}
|
|
141
|
+
}, { category: 'FILES' });
|
|
142
|
+
|
|
143
|
+
// ── /touch <path> ── Create empty file
|
|
144
|
+
reg('touch', 'Create an empty file', (args) => {
|
|
145
|
+
if (!args[0]) { console.log(ui.dim('Usage: /touch <file-path>')); return; }
|
|
146
|
+
const filePath = path.resolve(args.join(' '));
|
|
147
|
+
try {
|
|
148
|
+
const dir = path.dirname(filePath);
|
|
149
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
if (fs.existsSync(filePath)) {
|
|
151
|
+
// Update mtime like Unix touch
|
|
152
|
+
const now = new Date();
|
|
153
|
+
fs.utimesSync(filePath, now, now);
|
|
154
|
+
console.log(ui.success(`Touched: ${filePath}`));
|
|
155
|
+
} else {
|
|
156
|
+
fs.writeFileSync(filePath, '');
|
|
157
|
+
console.log(ui.success(`Created: ${filePath}`));
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.log(ui.error(e.message));
|
|
161
|
+
}
|
|
162
|
+
}, { category: 'FILES' });
|
|
163
|
+
|
|
164
|
+
};
|
package/lib/commands/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const { register } = require('../registry');
|
|
|
6
6
|
const moduleNames = [
|
|
7
7
|
'network', 'mcp', 'lucas', 'docker', 'database', 'cloudflare',
|
|
8
8
|
'ai', 'azure', 'agents', 'tasks', 'keys', 'setup', 'system',
|
|
9
|
-
'learn', 'sandbox', 'nvidia', 'edge', 'conversations',
|
|
9
|
+
'learn', 'sandbox', 'nvidia', 'edge', 'conversations', 'audit', 'compute', 'files',
|
|
10
10
|
];
|
|
11
11
|
|
|
12
12
|
function loadAll() {
|
package/package.json
CHANGED