kova-node-cli 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.
@@ -0,0 +1,607 @@
1
+ import Fastify from 'fastify';
2
+ import cors from '@fastify/cors';
3
+ import websocket from '@fastify/websocket';
4
+ import { logger } from '../lib/logger.js';
5
+ import Docker from 'dockerode';
6
+ import http from 'http';
7
+ export class NodeAPIServer {
8
+ app;
9
+ containerManager;
10
+ deploymentExecutor;
11
+ port;
12
+ authToken;
13
+ constructor(containerManager, deploymentExecutor, port = 4002, accessToken) {
14
+ this.containerManager = containerManager;
15
+ this.deploymentExecutor = deploymentExecutor;
16
+ this.port = port;
17
+ // token for authenticating requests from orchestrator
18
+ this.authToken = accessToken || process.env.PROVIDER_TOKEN || '';
19
+ }
20
+ getAccessToken() {
21
+ return this.authToken;
22
+ }
23
+ // verify request has valid auth token
24
+ verifyAuth(request, reply) {
25
+ // health check doesn't need auth
26
+ if (request.url === '/health') {
27
+ return true;
28
+ }
29
+ const authHeader = request.headers.authorization;
30
+ if (!authHeader) {
31
+ reply.code(401).send({ error: 'authorization required' });
32
+ return false;
33
+ }
34
+ const token = authHeader.replace('Bearer ', '');
35
+ if (!this.authToken || token !== this.authToken) {
36
+ logger.warn({ url: request.url }, 'invalid auth token');
37
+ reply.code(401).send({ error: 'invalid token' });
38
+ return false;
39
+ }
40
+ return true;
41
+ }
42
+ async start() {
43
+ this.app = Fastify({ logger: logger });
44
+ // cors config - restrict in production
45
+ const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
46
+ const isDev = process.env.NODE_ENV !== 'production';
47
+ await this.app.register(cors, {
48
+ origin: (origin, callback) => {
49
+ if (!origin) {
50
+ callback(null, true);
51
+ return;
52
+ }
53
+ if (isDev && (origin.includes('localhost') || origin.includes('127.0.0.1'))) {
54
+ callback(null, true);
55
+ return;
56
+ }
57
+ if (allowedOrigins.includes(origin)) {
58
+ callback(null, true);
59
+ }
60
+ else {
61
+ callback(new Error('cors not allowed'), false);
62
+ }
63
+ }
64
+ });
65
+ // register websocket support for shell access
66
+ await this.app.register(websocket);
67
+ // exec command in container
68
+ this.app.post('/jobs/:jobId/exec', async (request, reply) => {
69
+ if (!this.verifyAuth(request, reply))
70
+ return;
71
+ const { jobId } = request.params;
72
+ const { command } = request.body;
73
+ if (!command) {
74
+ return reply.code(400).send({ error: 'command required' });
75
+ }
76
+ try {
77
+ const result = await this.containerManager.execInContainer(jobId, command);
78
+ return {
79
+ success: true,
80
+ ...result
81
+ };
82
+ }
83
+ catch (err) {
84
+ logger.error({ err, jobId, command }, 'exec failed');
85
+ return reply.code(500).send({
86
+ error: 'exec failed',
87
+ message: err.message
88
+ });
89
+ }
90
+ });
91
+ // get container logs
92
+ this.app.get('/jobs/:jobId/logs', async (request, reply) => {
93
+ if (!this.verifyAuth(request, reply))
94
+ return;
95
+ const { jobId } = request.params;
96
+ const tail = parseInt(request.query.tail || '100');
97
+ try {
98
+ const logs = await this.containerManager.getContainerLogs(jobId, tail);
99
+ return {
100
+ success: true,
101
+ logs
102
+ };
103
+ }
104
+ catch (err) {
105
+ logger.error({ err, jobId }, 'failed to get logs');
106
+ return reply.code(500).send({
107
+ error: 'failed to get logs',
108
+ message: err.message
109
+ });
110
+ }
111
+ });
112
+ // write file to container
113
+ this.app.post('/jobs/:jobId/files', async (request, reply) => {
114
+ if (!this.verifyAuth(request, reply))
115
+ return;
116
+ const { jobId } = request.params;
117
+ const { filepath, content } = request.body;
118
+ if (!filepath || content === undefined) {
119
+ return reply.code(400).send({ error: 'filepath and content required' });
120
+ }
121
+ try {
122
+ await this.containerManager.writeFile(jobId, filepath, content);
123
+ return { success: true };
124
+ }
125
+ catch (err) {
126
+ logger.error({ err, jobId, filepath }, 'failed to write file');
127
+ return reply.code(500).send({
128
+ error: 'failed to write file',
129
+ message: err.message
130
+ });
131
+ }
132
+ });
133
+ // read file from container
134
+ this.app.get('/jobs/:jobId/files/*', async (request, reply) => {
135
+ if (!this.verifyAuth(request, reply))
136
+ return;
137
+ const { jobId } = request.params;
138
+ const filepath = request.params['*'];
139
+ try {
140
+ const content = await this.containerManager.readFile(jobId, filepath);
141
+ return {
142
+ success: true,
143
+ filepath,
144
+ content
145
+ };
146
+ }
147
+ catch (err) {
148
+ logger.error({ err, jobId, filepath }, 'failed to read file');
149
+ return reply.code(500).send({
150
+ error: 'failed to read file',
151
+ message: err.message
152
+ });
153
+ }
154
+ });
155
+ // proxy http requests to deployment containers
156
+ this.app.all('/deployments/:deploymentId/proxy', async (request, reply) => {
157
+ if (!this.verifyAuth(request, reply))
158
+ return;
159
+ const { deploymentId } = request.params;
160
+ const targetPort = parseInt(request.headers['x-target-port'] || '80');
161
+ try {
162
+ // get container name from deployment id
163
+ // containers are named: kova-{deploymentId}-{serviceName}
164
+ // for now, assume first service (need to parse sdl for multi-service)
165
+ const containerName = `kova-${deploymentId}`;
166
+ // containers on same docker network can access each other by name
167
+ // or we can get container IP
168
+ const docker = new Docker();
169
+ const containers = await docker.listContainers({
170
+ filters: { label: [`kova.deployment=${deploymentId}`] }
171
+ });
172
+ if (containers.length === 0) {
173
+ return reply.code(404).send({ error: 'deployment container not found or not running' });
174
+ }
175
+ // get container IP from docker network
176
+ const containerData = await docker.getContainer(containers[0].Id).inspect();
177
+ const networks = containerData.NetworkSettings.Networks;
178
+ const networkName = Object.keys(networks)[0];
179
+ const containerIP = networks[networkName]?.IPAddress;
180
+ if (!containerIP) {
181
+ return reply.code(502).send({ error: 'container has no network ip' });
182
+ }
183
+ // proxy to container
184
+ const targetUrl = `http://${containerIP}:${targetPort}${request.url.replace(`/deployments/${deploymentId}/proxy`, '')}`;
185
+ logger.info({ targetUrl, deploymentId }, 'proxying request');
186
+ // strip sensitive headers before forwarding to customer container
187
+ const forwardHeaders = { ...request.headers };
188
+ delete forwardHeaders['authorization'];
189
+ delete forwardHeaders['cookie'];
190
+ delete forwardHeaders['x-target-port'];
191
+ forwardHeaders.host = `${containerIP}:${targetPort}`;
192
+ const proxyReq = http.request(targetUrl, {
193
+ method: request.method,
194
+ headers: forwardHeaders
195
+ }, (proxyRes) => {
196
+ reply.code(proxyRes.statusCode);
197
+ // copy all headers from container response
198
+ Object.keys(proxyRes.headers).forEach(key => {
199
+ reply.header(key, proxyRes.headers[key]);
200
+ });
201
+ // stream response directly
202
+ reply.send(proxyRes);
203
+ });
204
+ proxyReq.on('error', (err) => {
205
+ logger.error({ err, targetUrl }, 'proxy request failed');
206
+ reply.code(502).send({ error: 'proxy failed', message: err.message });
207
+ });
208
+ if (request.body) {
209
+ proxyReq.write(JSON.stringify(request.body));
210
+ }
211
+ proxyReq.end();
212
+ return reply;
213
+ }
214
+ catch (err) {
215
+ logger.error({ err, deploymentId }, 'proxy setup failed');
216
+ return reply.code(502).send({ error: 'proxy failed', message: err.message });
217
+ }
218
+ });
219
+ // update deployment files
220
+ this.app.post('/deployments/:deploymentId/services/:serviceName/update-files', async (request, reply) => {
221
+ if (!this.verifyAuth(request, reply))
222
+ return;
223
+ const { deploymentId, serviceName } = request.params;
224
+ if (!this.deploymentExecutor) {
225
+ return reply.code(503).send({ error: 'deployment executor not available' });
226
+ }
227
+ try {
228
+ await this.deploymentExecutor.updateDeploymentFiles(deploymentId, serviceName);
229
+ return {
230
+ success: true,
231
+ message: 'files updated and deployment restarted'
232
+ };
233
+ }
234
+ catch (err) {
235
+ logger.error({ err, deploymentId, serviceName }, 'failed to update deployment files');
236
+ return reply.code(500).send({
237
+ error: 'failed to update files',
238
+ message: err.message
239
+ });
240
+ }
241
+ });
242
+ // health check
243
+ this.app.get('/health', async () => {
244
+ return {
245
+ status: 'ok',
246
+ runningJobs: this.containerManager.getRunningJobs().length
247
+ };
248
+ });
249
+ // websocket shell endpoint for direct access (http fallback when p2p unavailable)
250
+ this.app.get('/deployments/:deploymentId/shell', { websocket: true }, async (connection, req) => {
251
+ // verify auth token from query param or header
252
+ const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
253
+ if (!this.authToken) {
254
+ logger.warn({ deploymentId: req.params.deploymentId }, 'shell access denied - no auth token configured on provider');
255
+ connection.socket.close(1008, 'provider auth not configured');
256
+ return;
257
+ }
258
+ if (!token || token !== this.authToken) {
259
+ logger.warn({ deploymentId: req.params.deploymentId, hasToken: !!token, tokenLen: token?.length }, 'shell access denied - token mismatch');
260
+ connection.socket.close(1008, 'unauthorized - invalid access token');
261
+ return;
262
+ }
263
+ const deploymentId = req.params.deploymentId;
264
+ const serviceName = req.query.service || 'web';
265
+ logger.info({ deploymentId, serviceName }, 'direct shell websocket connection');
266
+ if (!this.deploymentExecutor) {
267
+ connection.socket.close(1008, 'deployment executor not available');
268
+ return;
269
+ }
270
+ let sessionId = null;
271
+ connection.socket.on('message', async (data) => {
272
+ try {
273
+ const message = JSON.parse(data.toString());
274
+ if (message.type === 'init') {
275
+ // generate session id and start shell
276
+ sessionId = `shell-${deploymentId}-${Date.now()}`;
277
+ const result = await this.deploymentExecutor.startShellSession(sessionId, deploymentId, serviceName, (output) => {
278
+ // send output back to client
279
+ connection.socket.send(JSON.stringify({ type: 'output', data: output }));
280
+ });
281
+ if (result.success) {
282
+ connection.socket.send(JSON.stringify({ type: 'ready', sessionId }));
283
+ }
284
+ else {
285
+ connection.socket.send(JSON.stringify({ type: 'error', message: result.error || 'failed to start shell session' }));
286
+ connection.socket.close(1008, result.error || 'shell start failed');
287
+ }
288
+ }
289
+ else if (message.type === 'input' && sessionId) {
290
+ this.deploymentExecutor.sendShellInput(sessionId, message.data);
291
+ }
292
+ else if (message.type === 'resize' && sessionId) {
293
+ this.deploymentExecutor.resizeShell(sessionId, message.cols, message.rows);
294
+ }
295
+ }
296
+ catch (err) {
297
+ logger.error({ err, deploymentId }, 'shell message error');
298
+ connection.socket.send(JSON.stringify({ type: 'error', message: 'command failed' }));
299
+ }
300
+ });
301
+ connection.socket.on('close', () => {
302
+ if (sessionId && this.deploymentExecutor) {
303
+ this.deploymentExecutor.closeShellSession(sessionId);
304
+ }
305
+ logger.info({ deploymentId }, 'direct shell websocket closed');
306
+ });
307
+ });
308
+ // container stats (cpu, memory, network) for a deployment
309
+ this.app.get('/deployments/:deploymentId/stats', async (request, reply) => {
310
+ if (!this.verifyAuth(request, reply))
311
+ return;
312
+ const { deploymentId } = request.params;
313
+ try {
314
+ const stats = await this.deploymentExecutor.getDeploymentStats(deploymentId);
315
+ return stats;
316
+ }
317
+ catch (err) {
318
+ logger.error({ err, deploymentId }, 'failed to get deployment stats');
319
+ return reply.code(500).send({ error: 'failed to get stats', message: err.message });
320
+ }
321
+ });
322
+ // container status (running, exited, etc) for a deployment
323
+ this.app.get('/deployments/:deploymentId/status', async (request, reply) => {
324
+ if (!this.verifyAuth(request, reply))
325
+ return;
326
+ const { deploymentId } = request.params;
327
+ try {
328
+ const status = await this.deploymentExecutor.getDeploymentStatus(deploymentId);
329
+ return status;
330
+ }
331
+ catch (err) {
332
+ logger.error({ err, deploymentId }, 'failed to get deployment status');
333
+ return reply.code(500).send({ error: 'failed to get status', message: err.message });
334
+ }
335
+ });
336
+ // container events (create, start, stop, health) for a deployment
337
+ this.app.get('/deployments/:deploymentId/events', async (request, reply) => {
338
+ if (!this.verifyAuth(request, reply))
339
+ return;
340
+ const { deploymentId } = request.params;
341
+ if (!this.deploymentExecutor) {
342
+ return reply.code(503).send({ error: 'deployment executor not available' });
343
+ }
344
+ try {
345
+ const events = await this.deploymentExecutor.getContainerEvents(deploymentId);
346
+ if (events.error) {
347
+ return reply.code(404).send(events);
348
+ }
349
+ return events;
350
+ }
351
+ catch (err) {
352
+ logger.error({ err, deploymentId }, 'failed to get container events');
353
+ return reply.code(500).send({ error: 'failed to get events', message: err.message });
354
+ }
355
+ });
356
+ // stream stats via websocket for live metrics
357
+ this.app.get('/deployments/:deploymentId/stats/stream', { websocket: true }, async (connection, req) => {
358
+ const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
359
+ if (!this.authToken || !token || token !== this.authToken) {
360
+ connection.socket.close(1008, 'unauthorized');
361
+ return;
362
+ }
363
+ const { deploymentId } = req.params;
364
+ const intervalMs = Math.max(parseInt(req.query.interval) || 3000, 2000); // min 2s
365
+ let active = true;
366
+ const sendStats = async () => {
367
+ if (!active)
368
+ return;
369
+ try {
370
+ const stats = await this.deploymentExecutor.getDeploymentStats(deploymentId);
371
+ if (active && connection.socket.readyState === 1) {
372
+ connection.socket.send(JSON.stringify(stats));
373
+ }
374
+ }
375
+ catch (err) {
376
+ if (active && connection.socket.readyState === 1) {
377
+ connection.socket.send(JSON.stringify({ error: err.message }));
378
+ }
379
+ }
380
+ };
381
+ // send initial stats immediately
382
+ await sendStats();
383
+ const interval = setInterval(sendStats, intervalMs);
384
+ connection.socket.on('close', () => {
385
+ active = false;
386
+ clearInterval(interval);
387
+ });
388
+ connection.socket.on('error', () => {
389
+ active = false;
390
+ clearInterval(interval);
391
+ });
392
+ });
393
+ // browse files inside a deployment container
394
+ this.app.get('/deployments/:deploymentId/services/:serviceName/browse', async (request, reply) => {
395
+ if (!this.verifyAuth(request, reply))
396
+ return;
397
+ const { deploymentId, serviceName } = request.params;
398
+ const dirPath = request.query.path || '/';
399
+ if (!this.deploymentExecutor) {
400
+ return reply.code(503).send({ error: 'deployment executor not available' });
401
+ }
402
+ try {
403
+ const result = await this.deploymentExecutor.browseFiles(deploymentId, serviceName, dirPath);
404
+ if (result.error) {
405
+ return reply.code(404).send(result);
406
+ }
407
+ return result;
408
+ }
409
+ catch (err) {
410
+ return reply.code(500).send({ error: 'failed to browse files', message: err.message });
411
+ }
412
+ });
413
+ // read a file from inside a deployment container
414
+ this.app.get('/deployments/:deploymentId/services/:serviceName/cat/*', async (request, reply) => {
415
+ if (!this.verifyAuth(request, reply))
416
+ return;
417
+ const { deploymentId, serviceName } = request.params;
418
+ const filePath = '/' + (request.params['*'] || '');
419
+ if (!this.deploymentExecutor) {
420
+ return reply.code(503).send({ error: 'deployment executor not available' });
421
+ }
422
+ try {
423
+ const result = await this.deploymentExecutor.readContainerFile(deploymentId, serviceName, filePath);
424
+ if (result.error) {
425
+ return reply.code(result.error === 'file not found' ? 404 : 500).send(result);
426
+ }
427
+ return result;
428
+ }
429
+ catch (err) {
430
+ return reply.code(500).send({ error: 'failed to read file', message: err.message });
431
+ }
432
+ });
433
+ // upload a file into a deployment container
434
+ this.app.post('/deployments/:deploymentId/services/:serviceName/upload', async (request, reply) => {
435
+ if (!this.verifyAuth(request, reply))
436
+ return;
437
+ const { deploymentId, serviceName } = request.params;
438
+ const { path: filePath, content, encoding } = request.body || {};
439
+ if (!filePath || content === undefined) {
440
+ return reply.code(400).send({ error: 'path and content required' });
441
+ }
442
+ if (!this.deploymentExecutor) {
443
+ return reply.code(503).send({ error: 'deployment executor not available' });
444
+ }
445
+ try {
446
+ const result = await this.deploymentExecutor.uploadFileToContainer(deploymentId, serviceName, filePath, content, encoding || 'utf8');
447
+ if (!result.success) {
448
+ return reply.code(400).send(result);
449
+ }
450
+ return result;
451
+ }
452
+ catch (err) {
453
+ return reply.code(500).send({ error: 'failed to upload file', message: err.message });
454
+ }
455
+ });
456
+ // restart all containers in a deployment
457
+ this.app.post('/deployments/:deploymentId/restart', async (request, reply) => {
458
+ if (!this.verifyAuth(request, reply))
459
+ return;
460
+ const { deploymentId } = request.params;
461
+ if (!this.deploymentExecutor) {
462
+ return reply.code(503).send({ error: 'deployment executor not available' });
463
+ }
464
+ try {
465
+ const services = await this.deploymentExecutor.restartDeployment(deploymentId);
466
+ return { restarted: true, services };
467
+ }
468
+ catch (err) {
469
+ logger.error({ err, deploymentId }, 'failed to restart deployment');
470
+ return reply.code(500).send({ error: 'restart failed', message: err.message });
471
+ }
472
+ });
473
+ // create a volume snapshot for a service
474
+ this.app.post('/deployments/:deploymentId/snapshots', async (request, reply) => {
475
+ if (!this.verifyAuth(request, reply))
476
+ return;
477
+ const { deploymentId } = request.params;
478
+ const { serviceName, snapshotId } = request.body || {};
479
+ if (!serviceName || !snapshotId) {
480
+ return reply.code(400).send({ error: 'serviceName and snapshotId required' });
481
+ }
482
+ if (!this.deploymentExecutor) {
483
+ return reply.code(503).send({ error: 'deployment executor not available' });
484
+ }
485
+ try {
486
+ const result = await this.deploymentExecutor.createVolumeSnapshot(deploymentId, serviceName, snapshotId);
487
+ return {
488
+ snapshotId,
489
+ volumeName: result.volumeName,
490
+ sizeBytes: result.sizeBytes,
491
+ snapshotKey: result.snapshotKey,
492
+ state: 'ready'
493
+ };
494
+ }
495
+ catch (err) {
496
+ logger.error({ err, deploymentId, serviceName, snapshotId }, 'failed to create snapshot');
497
+ return reply.code(500).send({ error: 'snapshot failed', message: err.message });
498
+ }
499
+ });
500
+ // restore a volume snapshot
501
+ this.app.post('/deployments/:deploymentId/snapshots/:snapshotId/restore', async (request, reply) => {
502
+ if (!this.verifyAuth(request, reply))
503
+ return;
504
+ const { deploymentId, snapshotId } = request.params;
505
+ const { serviceName, snapshotKey } = request.body || {};
506
+ if (!serviceName || !snapshotKey) {
507
+ return reply.code(400).send({ error: 'serviceName and snapshotKey required' });
508
+ }
509
+ if (!this.deploymentExecutor) {
510
+ return reply.code(503).send({ error: 'deployment executor not available' });
511
+ }
512
+ try {
513
+ await this.deploymentExecutor.restoreVolumeSnapshot(deploymentId, serviceName, snapshotKey);
514
+ return { restored: true };
515
+ }
516
+ catch (err) {
517
+ logger.error({ err, deploymentId, snapshotId }, 'failed to restore snapshot');
518
+ return reply.code(500).send({ error: 'restore failed', message: err.message });
519
+ }
520
+ });
521
+ // scale container resources (stop, remove, recreate with new limits)
522
+ this.app.put('/deployments/:deploymentId/scale', async (request, reply) => {
523
+ if (!this.verifyAuth(request, reply))
524
+ return;
525
+ const { deploymentId } = request.params;
526
+ const { serviceName, cpu, memory } = request.body || {};
527
+ if (!serviceName) {
528
+ return reply.code(400).send({ error: 'serviceName required' });
529
+ }
530
+ if (!this.deploymentExecutor) {
531
+ return reply.code(503).send({ error: 'deployment executor not available' });
532
+ }
533
+ const deployment = this.deploymentExecutor.getDeployment(deploymentId);
534
+ if (!deployment) {
535
+ return reply.code(404).send({ error: 'deployment not found' });
536
+ }
537
+ const containerId = deployment.containers.get(serviceName);
538
+ if (!containerId) {
539
+ return reply.code(404).send({ error: `service ${serviceName} not found in deployment` });
540
+ }
541
+ try {
542
+ const docker = new Docker();
543
+ const container = docker.getContainer(containerId);
544
+ const info = await container.inspect();
545
+ // stop and remove old container
546
+ try {
547
+ await container.stop({ t: 10 });
548
+ }
549
+ catch { /* may already be stopped */ }
550
+ await container.remove();
551
+ // compute new resource limits
552
+ const newMemory = memory
553
+ ? this.parseMemoryString(memory)
554
+ : (info.HostConfig.Memory || 4 * 1024 * 1024 * 1024);
555
+ const newCpu = cpu || (info.HostConfig.CpuQuota ? info.HostConfig.CpuQuota / 100000 : 4);
556
+ // recreate with same config but new resource limits
557
+ const newConfig = {
558
+ name: info.Name.startsWith('/') ? info.Name.slice(1) : info.Name,
559
+ Image: info.Config.Image,
560
+ Env: info.Config.Env || [],
561
+ Cmd: info.Config.Cmd || undefined,
562
+ Entrypoint: info.Config.Entrypoint || undefined,
563
+ ExposedPorts: info.Config.ExposedPorts || {},
564
+ Labels: info.Config.Labels || {},
565
+ HostConfig: {
566
+ ...info.HostConfig,
567
+ Memory: newMemory,
568
+ MemorySwap: newMemory,
569
+ CpuPeriod: 100000,
570
+ CpuQuota: Math.floor(newCpu * 100000),
571
+ }
572
+ };
573
+ const newContainer = await docker.createContainer(newConfig);
574
+ await newContainer.start();
575
+ // update container id in executor
576
+ deployment.containers.set(serviceName, newContainer.id);
577
+ logger.info({ deploymentId, serviceName, cpu: newCpu, memory: newMemory }, 'container scaled');
578
+ return { scaled: true, serviceName };
579
+ }
580
+ catch (err) {
581
+ logger.error({ err, deploymentId, serviceName }, 'failed to scale container');
582
+ return reply.code(500).send({ error: 'scale failed', message: err.message });
583
+ }
584
+ });
585
+ await this.app.listen({ port: this.port, host: '0.0.0.0' });
586
+ logger.info({ port: this.port }, 'node api server started');
587
+ }
588
+ // parse memory string like "512Mi", "2Gi" into bytes
589
+ parseMemoryString(size) {
590
+ const units = {
591
+ 'K': 1000, 'M': 1000 ** 2, 'G': 1000 ** 3, 'T': 1000 ** 4,
592
+ 'Ki': 1024, 'Mi': 1024 ** 2, 'Gi': 1024 ** 3, 'Ti': 1024 ** 4,
593
+ };
594
+ const match = size.match(/^(\d+(?:\.\d+)?)\s*([A-Za-z]+)$/);
595
+ if (!match)
596
+ return 4 * 1024 * 1024 * 1024; // 4gb fallback
597
+ const value = parseFloat(match[1]);
598
+ const unit = match[2];
599
+ return Math.floor(value * (units[unit] || 1));
600
+ }
601
+ async stop() {
602
+ if (this.app) {
603
+ await this.app.close();
604
+ logger.info('node api server stopped');
605
+ }
606
+ }
607
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ import { Command } from 'commander';
2
+ import { startNode } from './commands/start.js';
3
+ import { statusCommand } from './commands/status.js';
4
+ import { stopCommand } from './commands/stop.js';
5
+ import { earningsCommand } from './commands/earnings.js';
6
+ import { registerDeployCommands } from './commands/deploy.js';
7
+ import pino from 'pino';
8
+ // eh whatever logger setup
9
+ const logger = pino({
10
+ transport: process.env.NODE_ENV !== 'production' ? {
11
+ target: 'pino-pretty',
12
+ options: { colorize: true }
13
+ } : undefined
14
+ });
15
+ const program = new Command();
16
+ program
17
+ .name('kova-node')
18
+ .description('decentralized compute node - earn by sharing resources')
19
+ .version('0.0.1');
20
+ program
21
+ .command('start')
22
+ .description('start earning with your spare compute')
23
+ .option('-k, --api-key <key>', 'your provider api key (get from dashboard)')
24
+ .option('-w, --wallet <address>', 'your ethereum wallet address (legacy)')
25
+ .option('-p, --port <number>', 'p2p port', '4001')
26
+ .option('-c, --config <path>', 'config file path')
27
+ .option('--max-cpu <cores>', 'maximum CPU cores to allocate (default: all available)')
28
+ .option('--max-memory <gb>', 'maximum memory in GB to allocate (default: 80% of total)')
29
+ .option('--max-disk <gb>', 'maximum disk space in GB to allocate (default: 100GB)')
30
+ .option('-v, --verbose', 'show me everything')
31
+ .action(startNode);
32
+ program
33
+ .command('status')
34
+ .description('check how things are going')
35
+ .action(statusCommand);
36
+ program
37
+ .command('stop')
38
+ .description('stop the node gracefully')
39
+ .action(stopCommand);
40
+ program
41
+ .command('earnings')
42
+ .description('see how much youve made')
43
+ .action(earningsCommand);
44
+ // deployment management commands (customer side)
45
+ registerDeployCommands(program);
46
+ // lets gooo
47
+ program.parse(process.argv);