@lamalibre/install-portlama-e2e-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install-portlama-e2e-mcp.js +30 -0
- package/package.json +47 -0
- package/src/config.js +60 -0
- package/src/index.js +96 -0
- package/src/install.js +262 -0
- package/src/lib/deps.js +162 -0
- package/src/lib/logs.js +146 -0
- package/src/lib/multipass.js +161 -0
- package/src/lib/profiles.js +69 -0
- package/src/lib/state.js +81 -0
- package/src/tools/env.js +45 -0
- package/src/tools/provision.js +353 -0
- package/src/tools/snapshots.js +126 -0
- package/src/tools/status.js +161 -0
- package/src/tools/tests.js +489 -0
- package/src/tools/vm.js +186 -0
package/src/lib/logs.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Two-Tier Log Management
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Tier 1 (intermediate): Structured JSON in TEMP_DIR/runs/<id>/
|
|
5
|
+
// - summary.json: pass/fail counts, timing, error excerpts
|
|
6
|
+
// - tests/<name>.json: per-test structured result
|
|
7
|
+
// - logs/<name>.log: raw output (fetched on demand)
|
|
8
|
+
//
|
|
9
|
+
// Tier 2 (final/publish): Full Markdown in e2e-logs/ (current format)
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { TEMP_DIR } from '../config.js';
|
|
14
|
+
|
|
15
|
+
/** Validate that a path component contains no traversal sequences. */
|
|
16
|
+
function validatePathComponent(value, label) {
|
|
17
|
+
if (value.includes('/') || value.includes('\\') || value.includes('..')) {
|
|
18
|
+
throw new Error(`Invalid ${label}: must not contain path separators or ".."`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Create a new run directory and return its ID and paths. */
|
|
23
|
+
export function createRun() {
|
|
24
|
+
const id = new Date().toISOString().replace(/[:.]/g, '-');
|
|
25
|
+
const runDir = path.join(TEMP_DIR, 'runs', id);
|
|
26
|
+
const testsDir = path.join(runDir, 'tests');
|
|
27
|
+
const logsDir = path.join(runDir, 'logs');
|
|
28
|
+
|
|
29
|
+
fs.mkdirSync(testsDir, { recursive: true });
|
|
30
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
return { id, runDir, testsDir, logsDir };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Write a per-test structured result. */
|
|
36
|
+
export function writeTestResult(testsDir, testName, result) {
|
|
37
|
+
const filePath = path.join(testsDir, `${testName}.json`);
|
|
38
|
+
fs.writeFileSync(filePath, JSON.stringify(result, null, 2));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write raw log output for a test. */
|
|
42
|
+
export function writeTestLog(logsDir, testName, output) {
|
|
43
|
+
const filePath = path.join(logsDir, `${testName}.log`);
|
|
44
|
+
fs.writeFileSync(filePath, output);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Write the run summary. */
|
|
48
|
+
export function writeSummary(runDir, summary) {
|
|
49
|
+
const filePath = path.join(runDir, 'summary.json');
|
|
50
|
+
fs.writeFileSync(filePath, JSON.stringify(summary, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a run summary. Returns null if not found. */
|
|
54
|
+
export function readSummary(runId) {
|
|
55
|
+
validatePathComponent(runId, 'runId');
|
|
56
|
+
const filePath = path.join(TEMP_DIR, 'runs', runId, 'summary.json');
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read raw log for a specific test in a run. */
|
|
65
|
+
export function readTestLog(runId, testName) {
|
|
66
|
+
validatePathComponent(runId, 'runId');
|
|
67
|
+
validatePathComponent(testName, 'testName');
|
|
68
|
+
const filePath = path.join(TEMP_DIR, 'runs', runId, 'logs', `${testName}.log`);
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read structured result for a specific test in a run. */
|
|
77
|
+
export function readTestResult(runId, testName) {
|
|
78
|
+
validatePathComponent(runId, 'runId');
|
|
79
|
+
validatePathComponent(testName, 'testName');
|
|
80
|
+
const filePath = path.join(TEMP_DIR, 'runs', runId, 'tests', `${testName}.json`);
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** List all run IDs, most recent first. */
|
|
89
|
+
export function listRuns() {
|
|
90
|
+
const runsDir = path.join(TEMP_DIR, 'runs');
|
|
91
|
+
try {
|
|
92
|
+
return fs.readdirSync(runsDir).sort().reverse();
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse raw test output to extract error lines.
|
|
100
|
+
* Looks for [FAIL] markers and assertion failures.
|
|
101
|
+
*/
|
|
102
|
+
export function extractErrors(rawOutput) {
|
|
103
|
+
const lines = rawOutput.split('\n');
|
|
104
|
+
const errors = [];
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const stripped = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').trim();
|
|
108
|
+
if (
|
|
109
|
+
stripped.includes('[FAIL]') ||
|
|
110
|
+
stripped.includes('assertion failed') ||
|
|
111
|
+
stripped.includes('FATAL') ||
|
|
112
|
+
stripped.includes('returned 500') ||
|
|
113
|
+
stripped.includes('exit code')
|
|
114
|
+
) {
|
|
115
|
+
errors.push(stripped);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a compact summary suitable for MCP tool response.
|
|
124
|
+
* Only includes errors for failed tests — saves context.
|
|
125
|
+
*/
|
|
126
|
+
export function buildCompactSummary(summary) {
|
|
127
|
+
const lines = [];
|
|
128
|
+
lines.push(`Run: ${summary.runId}`);
|
|
129
|
+
lines.push(`Passed: ${summary.passed}, Failed: ${summary.failed}, Skipped: ${summary.skipped}`);
|
|
130
|
+
lines.push(`Duration: ${Math.round(summary.durationMs / 1000)}s`);
|
|
131
|
+
|
|
132
|
+
if (summary.failed > 0) {
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push('Failures:');
|
|
135
|
+
for (const test of summary.tests) {
|
|
136
|
+
if (test.status === 'failed') {
|
|
137
|
+
lines.push(` ${test.name}:`);
|
|
138
|
+
for (const err of test.errors || []) {
|
|
139
|
+
lines.push(` - ${err}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Multipass CLI wrapper
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// All VM interactions go through this module. Uses execa with array arguments
|
|
5
|
+
// per project convention — no shell interpolation.
|
|
6
|
+
|
|
7
|
+
import { execa } from 'execa';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a multipass command and return { stdout, stderr, exitCode }.
|
|
11
|
+
* Throws on non-zero exit unless `allowFailure` is set.
|
|
12
|
+
*/
|
|
13
|
+
export async function run(args, { allowFailure = false, timeout = 120_000 } = {}) {
|
|
14
|
+
try {
|
|
15
|
+
const result = await execa('multipass', args, { timeout });
|
|
16
|
+
return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (allowFailure) {
|
|
19
|
+
return {
|
|
20
|
+
stdout: err.stdout || '',
|
|
21
|
+
stderr: err.stderr || err.message,
|
|
22
|
+
exitCode: err.exitCode || 1,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Launch a new VM with the given specs. */
|
|
30
|
+
export async function launch(name, { cpus, memory, disk }) {
|
|
31
|
+
return run([
|
|
32
|
+
'launch',
|
|
33
|
+
'24.04',
|
|
34
|
+
'--name',
|
|
35
|
+
name,
|
|
36
|
+
'--cpus',
|
|
37
|
+
String(cpus),
|
|
38
|
+
'--memory',
|
|
39
|
+
memory,
|
|
40
|
+
'--disk',
|
|
41
|
+
disk,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Delete a VM and purge all deleted instances. */
|
|
46
|
+
export async function deleteVm(name) {
|
|
47
|
+
await run(['delete', name], { allowFailure: true });
|
|
48
|
+
await run(['purge'], { allowFailure: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Delete a VM without purging (caller handles purge). */
|
|
52
|
+
export async function deleteVmNoPurge(name) {
|
|
53
|
+
await run(['delete', name], { allowFailure: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get info for a VM as JSON. Returns null if VM doesn't exist. */
|
|
57
|
+
export async function info(name) {
|
|
58
|
+
const result = await run(['info', name, '--format', 'json'], {
|
|
59
|
+
allowFailure: true,
|
|
60
|
+
});
|
|
61
|
+
if (result.exitCode !== 0) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(result.stdout);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get the IPv4 address of a VM. Returns null if unavailable. */
|
|
70
|
+
export async function getIp(name) {
|
|
71
|
+
const data = await info(name);
|
|
72
|
+
if (!data?.info?.[name]?.ipv4?.[0]) return null;
|
|
73
|
+
return data.info[name].ipv4[0];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** List all VMs. Returns an array of { name, state, ipv4 }. */
|
|
77
|
+
export async function list() {
|
|
78
|
+
const result = await run(['list', '--format', 'json'], {
|
|
79
|
+
allowFailure: true,
|
|
80
|
+
});
|
|
81
|
+
if (result.exitCode !== 0) return [];
|
|
82
|
+
try {
|
|
83
|
+
const data = JSON.parse(result.stdout);
|
|
84
|
+
return (data.list || []).map((vm) => ({
|
|
85
|
+
name: vm.name,
|
|
86
|
+
state: vm.state,
|
|
87
|
+
ipv4: vm.ipv4?.[0] || null,
|
|
88
|
+
}));
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Execute a command on a VM. */
|
|
95
|
+
export async function exec(
|
|
96
|
+
vmName,
|
|
97
|
+
command,
|
|
98
|
+
{ sudo = false, timeout = 120_000, allowFailure = false } = {},
|
|
99
|
+
) {
|
|
100
|
+
const args = ['exec', vmName, '--'];
|
|
101
|
+
if (sudo) args.push('sudo');
|
|
102
|
+
|
|
103
|
+
// command can be a string or array
|
|
104
|
+
if (typeof command === 'string') {
|
|
105
|
+
args.push('bash', '-c', command);
|
|
106
|
+
} else if (Array.isArray(command)) {
|
|
107
|
+
args.push(...command);
|
|
108
|
+
} else {
|
|
109
|
+
throw new Error(`exec: command must be a string or array, got ${typeof command}: ${JSON.stringify(command)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return run(args, { allowFailure, timeout });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Transfer a file to a VM. */
|
|
116
|
+
export async function transfer(localPath, vmDest) {
|
|
117
|
+
return run(['transfer', localPath, vmDest]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Transfer a file from a VM to local. */
|
|
121
|
+
export async function transferFrom(vmSource, localPath) {
|
|
122
|
+
return run(['transfer', vmSource, localPath]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Create a snapshot of a VM. */
|
|
126
|
+
export async function snapshot(vmName, snapshotName) {
|
|
127
|
+
return run(['snapshot', vmName, '--name', snapshotName]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Restore a VM to a named snapshot. */
|
|
131
|
+
export async function restore(vmName, snapshotName) {
|
|
132
|
+
return run(['restore', '--destructive', `${vmName}.${snapshotName}`]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** List snapshots for a VM. Returns array of snapshot names. */
|
|
136
|
+
export async function listSnapshots(vmName) {
|
|
137
|
+
const result = await run(['list', '--snapshots', '--format', 'json'], {
|
|
138
|
+
allowFailure: true,
|
|
139
|
+
});
|
|
140
|
+
if (result.exitCode !== 0) return [];
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(result.stdout);
|
|
143
|
+
// multipass list --snapshots --format json returns { "info": { "vm-name": { "snap-name": {...} } } }
|
|
144
|
+
const vmInfo = data?.info?.[vmName];
|
|
145
|
+
if (!vmInfo || typeof vmInfo !== 'object') return [];
|
|
146
|
+
return Object.keys(vmInfo);
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Delete a specific snapshot. */
|
|
153
|
+
export async function deleteSnapshot(vmName, snapshotName) {
|
|
154
|
+
return run(['delete', `${vmName}.${snapshotName}`], { allowFailure: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Check if multipass is installed and running. */
|
|
158
|
+
export async function isAvailable() {
|
|
159
|
+
const result = await run(['version'], { allowFailure: true });
|
|
160
|
+
return result.exitCode === 0;
|
|
161
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// VM Profile Selection & Hardware Detection
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { PROFILES } from '../config.js';
|
|
7
|
+
|
|
8
|
+
/** Parse a memory string like "2G" or "512M" into megabytes. */
|
|
9
|
+
function parseMemoryMB(mem) {
|
|
10
|
+
const match = mem.match(/^(\d+(?:\.\d+)?)\s*([MG])$/i);
|
|
11
|
+
if (!match) return 0;
|
|
12
|
+
const value = parseFloat(match[1]);
|
|
13
|
+
const unit = match[2].toUpperCase();
|
|
14
|
+
return unit === 'G' ? value * 1024 : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Detect host hardware capabilities. */
|
|
18
|
+
export function detectHardware() {
|
|
19
|
+
const cpus = os.cpus().length;
|
|
20
|
+
const totalMemoryGB = os.totalmem() / (1024 * 1024 * 1024);
|
|
21
|
+
const freeMemoryGB = os.freemem() / (1024 * 1024 * 1024);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
cpus,
|
|
25
|
+
totalMemoryGB: Math.round(totalMemoryGB * 10) / 10,
|
|
26
|
+
freeMemoryGB: Math.round(freeMemoryGB * 10) / 10,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recommend a VM profile based on available hardware.
|
|
32
|
+
* Returns { profile, name, supported[], note }.
|
|
33
|
+
*/
|
|
34
|
+
export function recommendProfile(hardware) {
|
|
35
|
+
const vmCount = 3; // host + agent + visitor
|
|
36
|
+
const hostReserveGB = 2; // leave for the host OS
|
|
37
|
+
const availableMemoryGB = hardware.freeMemoryGB - hostReserveGB;
|
|
38
|
+
const availableCpus = Math.max(1, hardware.cpus - 2);
|
|
39
|
+
|
|
40
|
+
const supported = [];
|
|
41
|
+
|
|
42
|
+
// Check each profile from most demanding to least
|
|
43
|
+
for (const [name, profile] of Object.entries(PROFILES).reverse()) {
|
|
44
|
+
const perVmMB = parseMemoryMB(profile.memory);
|
|
45
|
+
const totalNeededMB = perVmMB * vmCount;
|
|
46
|
+
const totalNeededGB = totalNeededMB / 1024;
|
|
47
|
+
const cpusNeeded = profile.cpus * vmCount;
|
|
48
|
+
|
|
49
|
+
if (totalNeededGB <= availableMemoryGB && cpusNeeded <= availableCpus) {
|
|
50
|
+
supported.push(name);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// production always fits (512M × 3 = 1.5G)
|
|
55
|
+
if (!supported.includes('production')) {
|
|
56
|
+
supported.push('production');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Recommend the most capable supported profile
|
|
60
|
+
const preference = ['performance', 'development', 'production'];
|
|
61
|
+
const recommended = preference.find((p) => supported.includes(p)) || 'production';
|
|
62
|
+
|
|
63
|
+
const profile = PROFILES[recommended];
|
|
64
|
+
const note =
|
|
65
|
+
`${vmCount} VMs × ${profile.memory} = ${(parseMemoryMB(profile.memory) * vmCount) / 1024}G` +
|
|
66
|
+
` (${Math.round(availableMemoryGB * 10) / 10}G available after ${hostReserveGB}G host reserve)`;
|
|
67
|
+
|
|
68
|
+
return { profile, name: recommended, supported, note };
|
|
69
|
+
}
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// State Management
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Tracks VM state, run history, and snapshot inventory in a JSON file.
|
|
5
|
+
// Written to TEMP_DIR/state.json for intermediate runs.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { TEMP_DIR } from '../config.js';
|
|
10
|
+
|
|
11
|
+
const STATE_FILE = path.join(TEMP_DIR, 'state.json');
|
|
12
|
+
|
|
13
|
+
/** Ensure TEMP_DIR exists with owner-only permissions. */
|
|
14
|
+
function ensureTempDir() {
|
|
15
|
+
if (!fs.existsSync(TEMP_DIR)) {
|
|
16
|
+
fs.mkdirSync(TEMP_DIR, { recursive: true, mode: 0o700 });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultState() {
|
|
21
|
+
return {
|
|
22
|
+
vms: {},
|
|
23
|
+
profile: null,
|
|
24
|
+
domain: null,
|
|
25
|
+
credentials: null,
|
|
26
|
+
lastRun: null,
|
|
27
|
+
runs: [],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Load state from disk. Returns default state if file doesn't exist. */
|
|
32
|
+
export function loadState() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
35
|
+
return { ...defaultState(), ...JSON.parse(raw) };
|
|
36
|
+
} catch {
|
|
37
|
+
return defaultState();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Persist state to disk with restricted permissions (0o600). */
|
|
42
|
+
export function saveState(state) {
|
|
43
|
+
ensureTempDir();
|
|
44
|
+
const tmp = STATE_FILE + '.tmp';
|
|
45
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
46
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Update a subset of state fields. */
|
|
50
|
+
export function updateState(partial) {
|
|
51
|
+
const state = loadState();
|
|
52
|
+
Object.assign(state, partial);
|
|
53
|
+
saveState(state);
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Record VM info in state. */
|
|
58
|
+
export function setVmState(name, info) {
|
|
59
|
+
const state = loadState();
|
|
60
|
+
state.vms[name] = { ...state.vms[name], ...info, updatedAt: new Date().toISOString() };
|
|
61
|
+
saveState(state);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove a VM from state. */
|
|
65
|
+
export function removeVmState(name) {
|
|
66
|
+
const state = loadState();
|
|
67
|
+
delete state.vms[name];
|
|
68
|
+
saveState(state);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Record a test run result. */
|
|
72
|
+
export function recordRun(run) {
|
|
73
|
+
const state = loadState();
|
|
74
|
+
state.lastRun = run.id;
|
|
75
|
+
state.runs.push(run);
|
|
76
|
+
// Keep last 20 runs
|
|
77
|
+
if (state.runs.length > 20) {
|
|
78
|
+
state.runs = state.runs.slice(-20);
|
|
79
|
+
}
|
|
80
|
+
saveState(state);
|
|
81
|
+
}
|
package/src/tools/env.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// env_detect — Hardware detection and profile recommendation
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { detectHardware, recommendProfile } from '../lib/profiles.js';
|
|
7
|
+
import { PROFILES } from '../config.js';
|
|
8
|
+
|
|
9
|
+
export const envDetectTool = {
|
|
10
|
+
name: 'env_detect',
|
|
11
|
+
description:
|
|
12
|
+
'Detect host hardware capabilities and recommend a VM profile. ' +
|
|
13
|
+
'Returns CPU count, available memory, recommended profile, and all supported profiles. ' +
|
|
14
|
+
'Run this before vm_create to choose the right resource tier.',
|
|
15
|
+
inputSchema: z.object({}),
|
|
16
|
+
async handler() {
|
|
17
|
+
const hardware = detectHardware();
|
|
18
|
+
const recommendation = recommendProfile(hardware);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: 'text',
|
|
24
|
+
text: JSON.stringify(
|
|
25
|
+
{
|
|
26
|
+
host: {
|
|
27
|
+
cpus: hardware.cpus,
|
|
28
|
+
totalMemoryGB: hardware.totalMemoryGB,
|
|
29
|
+
freeMemoryGB: hardware.freeMemoryGB,
|
|
30
|
+
},
|
|
31
|
+
recommendedProfile: recommendation.name,
|
|
32
|
+
supportedProfiles: recommendation.supported,
|
|
33
|
+
profileSpecs: Object.fromEntries(
|
|
34
|
+
recommendation.supported.map((name) => [name, PROFILES[name]]),
|
|
35
|
+
),
|
|
36
|
+
note: recommendation.note,
|
|
37
|
+
},
|
|
38
|
+
null,
|
|
39
|
+
2,
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
};
|