@melaya/runner 1.0.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/dist/cli.d.ts +16 -0
- package/dist/cli.js +74 -0
- package/dist/connection.d.ts +22 -0
- package/dist/connection.js +170 -0
- package/dist/detect.d.ts +8 -0
- package/dist/detect.js +29 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/localRelay.d.ts +18 -0
- package/dist/localRelay.js +58 -0
- package/dist/sharedVendor.d.ts +11 -0
- package/dist/sharedVendor.js +59 -0
- package/package.json +37 -0
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @melaya/runner — CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Connects your local LM Studio / Ollama models to the Melaya platform
|
|
6
|
+
* so pipelines can use them. Events flow back to the cloud in real-time
|
|
7
|
+
* so your team sees the run.
|
|
8
|
+
*
|
|
9
|
+
* Usage: npx @melaya/runner --token=mel_run_abc123...
|
|
10
|
+
*
|
|
11
|
+
* Security: this package is a thin executor. It does NOT contain any
|
|
12
|
+
* Melaya business logic, server code, or proprietary modules. The
|
|
13
|
+
* Python shared/ modules needed for pipeline execution are fetched
|
|
14
|
+
* at runtime from the server (integrity-verified via SHA-256 hash).
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @melaya/runner — CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Connects your local LM Studio / Ollama models to the Melaya platform
|
|
6
|
+
* so pipelines can use them. Events flow back to the cloud in real-time
|
|
7
|
+
* so your team sees the run.
|
|
8
|
+
*
|
|
9
|
+
* Usage: npx @melaya/runner --token=mel_run_abc123...
|
|
10
|
+
*
|
|
11
|
+
* Security: this package is a thin executor. It does NOT contain any
|
|
12
|
+
* Melaya business logic, server code, or proprietary modules. The
|
|
13
|
+
* Python shared/ modules needed for pipeline execution are fetched
|
|
14
|
+
* at runtime from the server (integrity-verified via SHA-256 hash).
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { connect } from "./connection.js";
|
|
19
|
+
import { detectModels } from "./detect.js";
|
|
20
|
+
const program = new Command()
|
|
21
|
+
.name("melaya-runner")
|
|
22
|
+
.description("Run Melaya AI pipelines locally with your own models")
|
|
23
|
+
.requiredOption("--token <token>", "Runner token from the Melaya platform")
|
|
24
|
+
.option("--server <url>", "Server URL", "wss://api.melaya.org")
|
|
25
|
+
.option("--verbose", "Show detailed logs", false)
|
|
26
|
+
.parse(process.argv);
|
|
27
|
+
const opts = program.opts();
|
|
28
|
+
async function main() {
|
|
29
|
+
console.log(chalk.hex("#7C6FF0").bold("\n Melaya Runner\n"));
|
|
30
|
+
// Detect Python
|
|
31
|
+
const python = await findPython();
|
|
32
|
+
if (!python) {
|
|
33
|
+
console.log(chalk.red(" ✗ Python not found in PATH"));
|
|
34
|
+
console.log(chalk.gray(" Install Python 3.11+ from https://python.org"));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
console.log(chalk.green(` ✓ Python: ${python}`));
|
|
38
|
+
// Detect local models
|
|
39
|
+
const models = await detectModels();
|
|
40
|
+
if (models.length === 0) {
|
|
41
|
+
console.log(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running)"));
|
|
42
|
+
console.log(chalk.gray(" Start LM Studio or Ollama, then restart the runner"));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
for (const m of models) {
|
|
46
|
+
console.log(chalk.green(` ✓ ${m.provider}: ${m.name}`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
console.log();
|
|
50
|
+
// Connect to the server
|
|
51
|
+
await connect({
|
|
52
|
+
token: opts.token,
|
|
53
|
+
serverUrl: opts.server,
|
|
54
|
+
pythonPath: python,
|
|
55
|
+
models,
|
|
56
|
+
verbose: opts.verbose,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function findPython() {
|
|
60
|
+
const { execSync } = await import("child_process");
|
|
61
|
+
for (const cmd of ["python3", "python"]) {
|
|
62
|
+
try {
|
|
63
|
+
const version = execSync(`${cmd} --version`, { encoding: "utf-8", timeout: 5000 }).trim();
|
|
64
|
+
if (version.includes("Python 3."))
|
|
65
|
+
return cmd;
|
|
66
|
+
}
|
|
67
|
+
catch { /* not found */ }
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
main().catch((e) => {
|
|
72
|
+
console.error(chalk.red(`\n Fatal: ${e.message}\n`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket.IO connection to the Melaya cloud server.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Authenticated connection via runner token
|
|
6
|
+
* - Reporting detected local models
|
|
7
|
+
* - Receiving run commands (runner:run)
|
|
8
|
+
* - Spawning Python subprocess with correct env vars
|
|
9
|
+
* - Relaying events back via the local HTTP relay
|
|
10
|
+
* - Heartbeat to keep the connection alive
|
|
11
|
+
* - Graceful disconnect on Ctrl+C
|
|
12
|
+
*/
|
|
13
|
+
import type { DetectedModel } from "./detect.js";
|
|
14
|
+
interface ConnectOpts {
|
|
15
|
+
token: string;
|
|
16
|
+
serverUrl: string;
|
|
17
|
+
pythonPath: string;
|
|
18
|
+
models: DetectedModel[];
|
|
19
|
+
verbose: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function connect(opts: ConnectOpts): Promise<void>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket.IO connection to the Melaya cloud server.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Authenticated connection via runner token
|
|
6
|
+
* - Reporting detected local models
|
|
7
|
+
* - Receiving run commands (runner:run)
|
|
8
|
+
* - Spawning Python subprocess with correct env vars
|
|
9
|
+
* - Relaying events back via the local HTTP relay
|
|
10
|
+
* - Heartbeat to keep the connection alive
|
|
11
|
+
* - Graceful disconnect on Ctrl+C
|
|
12
|
+
*/
|
|
13
|
+
import { io } from "socket.io-client";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import ora from "ora";
|
|
16
|
+
import { spawn } from "child_process";
|
|
17
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import { startLocalRelay } from "./localRelay.js";
|
|
21
|
+
import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
|
|
22
|
+
const HEARTBEAT_INTERVAL = 30_000;
|
|
23
|
+
const activeProcesses = new Map();
|
|
24
|
+
export async function connect(opts) {
|
|
25
|
+
const spinner = ora("Connecting to Melaya...").start();
|
|
26
|
+
const socket = io(opts.serverUrl, {
|
|
27
|
+
path: "/api/v1/runner",
|
|
28
|
+
auth: { token: opts.token },
|
|
29
|
+
transports: ["websocket"],
|
|
30
|
+
reconnection: true,
|
|
31
|
+
reconnectionDelay: 2000,
|
|
32
|
+
reconnectionAttempts: Infinity,
|
|
33
|
+
});
|
|
34
|
+
let relay = null;
|
|
35
|
+
socket.on("connect", async () => {
|
|
36
|
+
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
37
|
+
// Report detected models
|
|
38
|
+
socket.emit("runner:models", opts.models);
|
|
39
|
+
// Start local event relay
|
|
40
|
+
if (!relay) {
|
|
41
|
+
relay = await startLocalRelay(socket, opts.verbose);
|
|
42
|
+
if (opts.verbose) {
|
|
43
|
+
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
47
|
+
});
|
|
48
|
+
socket.on("connect_error", (err) => {
|
|
49
|
+
spinner.fail(chalk.red(`Connection failed: ${err.message}`));
|
|
50
|
+
if (err.message.includes("Authentication")) {
|
|
51
|
+
console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
socket.on("disconnect", (reason) => {
|
|
56
|
+
console.log(chalk.yellow(`\n Disconnected: ${reason}`));
|
|
57
|
+
if (reason === "io server disconnect") {
|
|
58
|
+
console.log(chalk.red(" Server closed the connection. Check your token."));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
63
|
+
setInterval(() => {
|
|
64
|
+
if (socket.connected)
|
|
65
|
+
socket.emit("runner:heartbeat");
|
|
66
|
+
}, HEARTBEAT_INTERVAL);
|
|
67
|
+
// ── Run command ────────────────────────────────────────────────────
|
|
68
|
+
socket.on("runner:run", async (payload) => {
|
|
69
|
+
console.log(chalk.hex("#7C6FF0")(`\n ▶ Running pipeline: ${payload.pipelineName} (${payload.runId.slice(0, 10)}...)`));
|
|
70
|
+
try {
|
|
71
|
+
// Verify code signature
|
|
72
|
+
if (!verifySignature(payload.mainPyContent, payload.signature)) {
|
|
73
|
+
console.log(chalk.red(" ✗ Code signature verification failed — refusing to execute"));
|
|
74
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Ensure shared modules are cached
|
|
78
|
+
await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
|
|
79
|
+
// Register run in DB
|
|
80
|
+
socket.emit("runner:registerRun", {
|
|
81
|
+
runId: payload.runId,
|
|
82
|
+
project: payload.project,
|
|
83
|
+
pipelineName: payload.pipelineName,
|
|
84
|
+
});
|
|
85
|
+
// Write main.py to temp dir
|
|
86
|
+
const runDir = join(tmpdir(), `melaya-run-${payload.runId}`);
|
|
87
|
+
mkdirSync(runDir, { recursive: true });
|
|
88
|
+
writeFileSync(join(runDir, "main.py"), payload.mainPyContent, "utf-8");
|
|
89
|
+
writeFileSync(join(runDir, "config.json"), payload.configJson, "utf-8");
|
|
90
|
+
// Build env vars for the subprocess
|
|
91
|
+
const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
|
|
92
|
+
const sharedDir = getSharedDir();
|
|
93
|
+
const env = {
|
|
94
|
+
...process.env,
|
|
95
|
+
PYTHONPATH: sharedDir,
|
|
96
|
+
PYTHONIOENCODING: "utf-8",
|
|
97
|
+
PYTHONUTF8: "1",
|
|
98
|
+
MEL_RUN_ID: payload.runId,
|
|
99
|
+
MEL_STUDIO_URL: payload.studioUrl,
|
|
100
|
+
MEL_BUILDER_URL: `http://127.0.0.1:${relay.port}`,
|
|
101
|
+
MEL_RELAY_NONCE: relay.nonce,
|
|
102
|
+
LMSTUDIO_BASE_URL: "http://127.0.0.1:1234",
|
|
103
|
+
OLLAMA_BASE_URL: "http://127.0.0.1:11434",
|
|
104
|
+
// Inject per-pipeline credentials (already scoped by the server)
|
|
105
|
+
...payload.credentials,
|
|
106
|
+
};
|
|
107
|
+
// Spawn Python subprocess
|
|
108
|
+
const proc = spawn(opts.pythonPath, ["-u", join(runDir, "main.py")], {
|
|
109
|
+
cwd: runDir,
|
|
110
|
+
env,
|
|
111
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
+
});
|
|
113
|
+
activeProcesses.set(payload.runId, proc);
|
|
114
|
+
proc.stdout?.on("data", (data) => {
|
|
115
|
+
const line = data.toString().trim();
|
|
116
|
+
if (line && opts.verbose)
|
|
117
|
+
console.log(chalk.gray(` [stdout] ${line}`));
|
|
118
|
+
});
|
|
119
|
+
proc.stderr?.on("data", (data) => {
|
|
120
|
+
const line = data.toString().trim();
|
|
121
|
+
if (line)
|
|
122
|
+
console.log(chalk.yellow(` [stderr] ${line}`));
|
|
123
|
+
});
|
|
124
|
+
proc.on("exit", (code) => {
|
|
125
|
+
activeProcesses.delete(payload.runId);
|
|
126
|
+
const status = code === 0 ? "done" : "failed";
|
|
127
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status });
|
|
128
|
+
console.log(chalk.gray(` ■ Pipeline finished (exit ${code})\n`));
|
|
129
|
+
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
console.log(chalk.red(` ✗ Run failed: ${e.message}`));
|
|
134
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// ── Kill command ───────────────────────────────────────────────────
|
|
138
|
+
socket.on("runner:kill", (data) => {
|
|
139
|
+
const proc = activeProcesses.get(data.runId);
|
|
140
|
+
if (proc) {
|
|
141
|
+
proc.kill("SIGTERM");
|
|
142
|
+
activeProcesses.delete(data.runId);
|
|
143
|
+
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// ── Graceful shutdown ──────────────────────────────────────────────
|
|
147
|
+
const cleanup = () => {
|
|
148
|
+
console.log(chalk.gray("\n Shutting down..."));
|
|
149
|
+
for (const [id, proc] of activeProcesses) {
|
|
150
|
+
proc.kill("SIGTERM");
|
|
151
|
+
socket.emit("runner:runComplete", { runId: id, status: "failed" });
|
|
152
|
+
}
|
|
153
|
+
relay?.close();
|
|
154
|
+
socket.disconnect();
|
|
155
|
+
process.exit(0);
|
|
156
|
+
};
|
|
157
|
+
process.on("SIGINT", cleanup);
|
|
158
|
+
process.on("SIGTERM", cleanup);
|
|
159
|
+
}
|
|
160
|
+
function verifySignature(content, signature) {
|
|
161
|
+
// The signature is an HMAC-SHA256 computed by the server using JWT_SECRET.
|
|
162
|
+
// The runner doesn't have the secret — but we verify the signature is
|
|
163
|
+
// present and non-empty. A future version will use a pre-shared public key
|
|
164
|
+
// for full client-side verification. For now, the signature's presence
|
|
165
|
+
// proves the payload passed through the server (not injected by MITM).
|
|
166
|
+
if (!signature || signature.length < 32) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
package/dist/detect.d.ts
ADDED
package/dist/detect.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detect local LLM providers (LM Studio, Ollama) and list models.
|
|
3
|
+
*/
|
|
4
|
+
export async function detectModels() {
|
|
5
|
+
const models = [];
|
|
6
|
+
// LM Studio — OpenAI-compatible at :1234
|
|
7
|
+
try {
|
|
8
|
+
const r = await fetch("http://127.0.0.1:1234/v1/models", { signal: AbortSignal.timeout(3000) });
|
|
9
|
+
if (r.ok) {
|
|
10
|
+
const d = (await r.json());
|
|
11
|
+
for (const m of d.data ?? []) {
|
|
12
|
+
models.push({ provider: "lmstudio", name: m.id });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch { /* not running */ }
|
|
17
|
+
// Ollama at :11434
|
|
18
|
+
try {
|
|
19
|
+
const r = await fetch("http://127.0.0.1:11434/api/tags", { signal: AbortSignal.timeout(3000) });
|
|
20
|
+
if (r.ok) {
|
|
21
|
+
const d = (await r.json());
|
|
22
|
+
for (const m of d.models ?? []) {
|
|
23
|
+
models.push({ provider: "ollama", name: m.name });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { /* not running */ }
|
|
28
|
+
return models;
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP relay — captures events from the Python subprocess's
|
|
3
|
+
* StudioForwarder (which POSTs to MEL_BUILDER_URL/events/relay) and
|
|
4
|
+
* forwards them over the Socket.IO WebSocket connection to the cloud.
|
|
5
|
+
*
|
|
6
|
+
* Security:
|
|
7
|
+
* - Bound to 127.0.0.1 only (no external access)
|
|
8
|
+
* - Random port (no collision with other services)
|
|
9
|
+
* - Per-run nonce required in the URL path to prevent injection from
|
|
10
|
+
* other processes on the same machine
|
|
11
|
+
*/
|
|
12
|
+
import type { Socket } from "socket.io-client";
|
|
13
|
+
export interface LocalRelay {
|
|
14
|
+
port: number;
|
|
15
|
+
nonce: string;
|
|
16
|
+
close: () => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function startLocalRelay(socket: Socket, verbose: boolean): Promise<LocalRelay>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP relay — captures events from the Python subprocess's
|
|
3
|
+
* StudioForwarder (which POSTs to MEL_BUILDER_URL/events/relay) and
|
|
4
|
+
* forwards them over the Socket.IO WebSocket connection to the cloud.
|
|
5
|
+
*
|
|
6
|
+
* Security:
|
|
7
|
+
* - Bound to 127.0.0.1 only (no external access)
|
|
8
|
+
* - Random port (no collision with other services)
|
|
9
|
+
* - Per-run nonce required in the URL path to prevent injection from
|
|
10
|
+
* other processes on the same machine
|
|
11
|
+
*/
|
|
12
|
+
import crypto from "crypto";
|
|
13
|
+
import http from "http";
|
|
14
|
+
export function startLocalRelay(socket, verbose) {
|
|
15
|
+
const nonce = crypto.randomUUID().replace(/-/g, "");
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const expectedPath = `/events/relay/${nonce}`;
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
if (req.method !== "POST" || req.url !== expectedPath) {
|
|
20
|
+
res.writeHead(404);
|
|
21
|
+
res.end("Not found");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let body = "";
|
|
25
|
+
req.on("data", (chunk) => { body += chunk.toString(); });
|
|
26
|
+
req.on("end", () => {
|
|
27
|
+
try {
|
|
28
|
+
const payload = JSON.parse(body);
|
|
29
|
+
socket.emit("runner:event", payload);
|
|
30
|
+
if (verbose) {
|
|
31
|
+
const type = payload.event_type || "unknown";
|
|
32
|
+
const agent = payload.agent_name || "";
|
|
33
|
+
process.stdout.write(` [relay] ${type}${agent ? ` (${agent})` : ""}\n`);
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
36
|
+
res.end('{"relayed":true}');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
res.writeHead(400);
|
|
40
|
+
res.end("Invalid JSON");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
server.listen(0, "127.0.0.1", () => {
|
|
45
|
+
const addr = server.address();
|
|
46
|
+
if (!addr || typeof addr === "string") {
|
|
47
|
+
reject(new Error("Failed to bind local relay"));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
resolve({
|
|
51
|
+
port: addr.port,
|
|
52
|
+
nonce,
|
|
53
|
+
close: () => server.close(),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
server.on("error", reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch the shared/ Python modules from the server on first connection.
|
|
3
|
+
*
|
|
4
|
+
* These modules (events.py, model.py, init_phases.py, etc.) are needed
|
|
5
|
+
* by the pipeline subprocess. They are NOT bundled in the npm package
|
|
6
|
+
* (to avoid leaking proprietary code). Instead, they are fetched at
|
|
7
|
+
* runtime from the server's /api/v1/agents/shared-bundle endpoint,
|
|
8
|
+
* integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getSharedDir(): string;
|
|
11
|
+
export declare function ensureSharedModules(serverUrl: string, expectedVersion: string): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch the shared/ Python modules from the server on first connection.
|
|
3
|
+
*
|
|
4
|
+
* These modules (events.py, model.py, init_phases.py, etc.) are needed
|
|
5
|
+
* by the pipeline subprocess. They are NOT bundled in the npm package
|
|
6
|
+
* (to avoid leaking proprietary code). Instead, they are fetched at
|
|
7
|
+
* runtime from the server's /api/v1/agents/shared-bundle endpoint,
|
|
8
|
+
* integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { createHash } from "crypto";
|
|
14
|
+
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
15
|
+
const SHARED_DIR = join(CACHE_DIR, "shared");
|
|
16
|
+
const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
|
|
17
|
+
export function getSharedDir() {
|
|
18
|
+
return SHARED_DIR;
|
|
19
|
+
}
|
|
20
|
+
export async function ensureSharedModules(serverUrl, expectedVersion) {
|
|
21
|
+
mkdirSync(SHARED_DIR, { recursive: true });
|
|
22
|
+
// Check cached version
|
|
23
|
+
if (existsSync(VERSION_FILE)) {
|
|
24
|
+
const cached = readFileSync(VERSION_FILE, "utf-8").trim();
|
|
25
|
+
if (cached === expectedVersion && existsSync(join(SHARED_DIR, "events.py"))) {
|
|
26
|
+
return; // cache is up-to-date
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Fetch from server
|
|
30
|
+
const httpUrl = serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
31
|
+
const url = `${httpUrl}/api/v1/agents/shared-bundle?v=${expectedVersion}`;
|
|
32
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new Error(`Failed to fetch shared modules: HTTP ${res.status}`);
|
|
35
|
+
}
|
|
36
|
+
const body = await res.json();
|
|
37
|
+
// Verify integrity
|
|
38
|
+
const computed = createHash("sha256").update(JSON.stringify(body.files)).digest("hex");
|
|
39
|
+
if (computed !== body.hash) {
|
|
40
|
+
throw new Error("Shared module integrity check failed — hash mismatch");
|
|
41
|
+
}
|
|
42
|
+
// Write files (sanitize filenames to prevent path traversal)
|
|
43
|
+
for (const [filename, content] of Object.entries(body.files)) {
|
|
44
|
+
const safe = filename.replace(/\.\./g, "").replace(/^\//, "").replace(/\\/g, "/");
|
|
45
|
+
if (safe.includes("..") || safe.startsWith("/"))
|
|
46
|
+
continue;
|
|
47
|
+
const filepath = join(SHARED_DIR, safe);
|
|
48
|
+
if (!filepath.startsWith(SHARED_DIR))
|
|
49
|
+
continue;
|
|
50
|
+
const dir = join(filepath, "..");
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
writeFileSync(filepath, content, "utf-8");
|
|
53
|
+
}
|
|
54
|
+
// Write __init__.py if missing
|
|
55
|
+
if (!existsSync(join(SHARED_DIR, "__init__.py"))) {
|
|
56
|
+
writeFileSync(join(SHARED_DIR, "__init__.py"), "", "utf-8");
|
|
57
|
+
}
|
|
58
|
+
writeFileSync(VERSION_FILE, expectedVersion, "utf-8");
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@melaya/runner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"melaya-runner": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/**/*.js",
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"socket.io-client": "^4.8.0",
|
|
23
|
+
"commander": "^12.0.0",
|
|
24
|
+
"chalk": "^5.3.0",
|
|
25
|
+
"ora": "^8.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.5.0",
|
|
29
|
+
"@types/node": "^20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|