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,358 @@
1
+ import { logger } from '../lib/logger.js';
2
+ import { P2PNode } from '../lib/p2p.js';
3
+ import { DockerManager } from '../lib/docker.js';
4
+ import { ResourceMonitor } from '../lib/monitor.js';
5
+ import { NodeConfig } from '../lib/config.js';
6
+ import { ContainerManager } from '../services/container-manager.js';
7
+ import { JobHandler } from '../services/job-handler.js';
8
+ import { HeartbeatService } from '../services/heartbeat.js';
9
+ import { NodeAPIServer } from '../api/server.js';
10
+ import { stateManager } from '../lib/state.js';
11
+ import { ResourceLimitManager } from '../lib/resource-limits.js';
12
+ import { AutoBidder } from '../services/auto-bidder.js';
13
+ import { LeaseHandler } from '../services/lease-handler.js';
14
+ import { DeploymentExecutor } from '../services/deployment-executor.js';
15
+ import { randomBytes } from 'crypto';
16
+ async function registerWithOrchestrator(nodeId, resources, apiKey, walletAddress, orchestratorUrl, maxRetries = 5) {
17
+ if (!orchestratorUrl) {
18
+ logger.warn('no orchestrator URL configured, skipping HTTP registration');
19
+ return null;
20
+ }
21
+ const body = {
22
+ nodeId,
23
+ resources,
24
+ timestamp: Date.now(),
25
+ version: '0.0.1'
26
+ };
27
+ // prefer api key over wallet
28
+ if (apiKey) {
29
+ body.apiKey = apiKey;
30
+ }
31
+ else if (walletAddress) {
32
+ body.walletAddress = walletAddress;
33
+ }
34
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
35
+ try {
36
+ const response = await fetch(`${orchestratorUrl}/api/v1/nodes/register`, {
37
+ method: 'POST',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify(body)
40
+ });
41
+ if (response.ok) {
42
+ const data = await response.json();
43
+ logger.info({
44
+ orchestratorUrl,
45
+ walletAddress: data.walletAddress,
46
+ providerId: data.providerId
47
+ }, 'registered with orchestrator');
48
+ return {
49
+ providerId: data.providerId,
50
+ walletAddress: data.walletAddress
51
+ };
52
+ }
53
+ else {
54
+ logger.warn({ status: response.status, attempt }, 'failed to register with orchestrator');
55
+ }
56
+ }
57
+ catch (err) {
58
+ logger.warn({ err, attempt, maxRetries }, 'could not reach orchestrator for HTTP registration');
59
+ }
60
+ if (attempt < maxRetries) {
61
+ const delay = Math.min(attempt * 3000, 15000);
62
+ logger.info({ attempt, nextRetryMs: delay }, 'retrying registration...');
63
+ await new Promise(resolve => setTimeout(resolve, delay));
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ export async function startNode(options) {
69
+ logger.info('starting kova node...');
70
+ // check for authentication (api key or wallet)
71
+ const apiKey = options.apiKey || options.k;
72
+ const walletAddress = options.wallet || options.w;
73
+ if (!apiKey && !walletAddress) {
74
+ console.error('\n❌ ERROR: Either --api-key or --wallet is required');
75
+ console.error('\nRecommended: kova-node start --api-key YOUR_API_KEY');
76
+ console.error('Legacy: kova-node start --wallet YOUR_WALLET_ADDRESS');
77
+ console.error('\nGet your API key from https://test.kovanetwork.com/provider');
78
+ process.exit(1);
79
+ }
80
+ // validate wallet address format if provided
81
+ if (walletAddress && !/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) {
82
+ console.error('\n❌ ERROR: Invalid wallet address format');
83
+ console.error('Wallet address must be a valid Ethereum address (0x followed by 40 hex characters)');
84
+ console.error(`Provided: ${walletAddress}`);
85
+ process.exit(1);
86
+ }
87
+ // validate api key format if provided
88
+ if (apiKey && !apiKey.startsWith('sk_live_')) {
89
+ console.error('\n❌ ERROR: Invalid API key format');
90
+ console.error('API key must start with sk_live_');
91
+ console.error(`Provided: ${apiKey.substring(0, 15)}...`);
92
+ process.exit(1);
93
+ }
94
+ // mark as running
95
+ stateManager.setRunning(process.pid);
96
+ // load config or use defaults
97
+ const config = await NodeConfig.load(options.config);
98
+ // setup resource limits based on CLI args
99
+ const limitManager = await ResourceLimitManager.createFromOptions({
100
+ maxCpu: options.maxCpu,
101
+ maxMemory: options.maxMemory,
102
+ maxDisk: options.maxDisk
103
+ });
104
+ // check if docker is even running
105
+ const docker = new DockerManager();
106
+ const dockerReady = await docker.checkDocker();
107
+ if (!dockerReady) {
108
+ logger.error('docker not running... install docker first');
109
+ process.exit(1);
110
+ }
111
+ // start monitoring resources
112
+ const monitor = new ResourceMonitor();
113
+ await monitor.start();
114
+ // join the p2p network
115
+ const p2p = new P2PNode({
116
+ port: parseInt(options.port) || 4001,
117
+ bootstrapNodes: config.bootstrapNodes
118
+ });
119
+ try {
120
+ await p2p.start();
121
+ const nodeId = p2p.getPeerId();
122
+ logger.info({ nodeId, walletAddress }, 'connected to kova network');
123
+ // get system resources but use provider limits
124
+ const systemResources = await monitor.getAvailableResources();
125
+ const providerLimits = limitManager.getLimits();
126
+ // advertise provider limits, not full system resources
127
+ const advertisedResources = {
128
+ cpu: {
129
+ cores: providerLimits.cpu,
130
+ available: providerLimits.cpu
131
+ },
132
+ memory: {
133
+ total: providerLimits.memory,
134
+ available: providerLimits.memory
135
+ },
136
+ disk: systemResources.disk,
137
+ network: systemResources.network,
138
+ gpu: systemResources.gpu || []
139
+ };
140
+ await p2p.advertiseCapabilities(advertisedResources);
141
+ // register with orchestrator via HTTP
142
+ const registrationResult = await registerWithOrchestrator(nodeId, advertisedResources, apiKey, walletAddress, config.orchestratorUrl);
143
+ const registered = !!registrationResult;
144
+ const effectiveWallet = registrationResult?.walletAddress || walletAddress;
145
+ if (registered) {
146
+ console.log('\n========================================');
147
+ console.log('✓ KOVA NODE STARTED SUCCESSFULLY');
148
+ console.log('========================================');
149
+ console.log(`Node ID: ${nodeId}`);
150
+ console.log(`Wallet: ${effectiveWallet}`);
151
+ console.log(`\nAllocated Resources:`);
152
+ console.log(`CPU: ${providerLimits.cpu} cores`);
153
+ console.log(`Memory: ${providerLimits.memory} GB`);
154
+ console.log(`Disk: ${providerLimits.disk} GB`);
155
+ console.log(`\nDashboard: https://test.kovanetwork.com`);
156
+ console.log('Connect with your wallet to view earnings');
157
+ console.log('========================================\n');
158
+ }
159
+ else {
160
+ console.log('\n⚠️ Node started but registration failed');
161
+ console.log('Check orchestrator connection and try again\n');
162
+ }
163
+ // generate access token for orchestrator → provider shell proxy auth
164
+ const accessToken = randomBytes(32).toString('hex');
165
+ const apiPort = config.apiPort || 4002;
166
+ // start heartbeat service to keep orchestrator updated
167
+ let heartbeat = null;
168
+ if (config.orchestratorUrl) {
169
+ heartbeat = new HeartbeatService(nodeId, config.orchestratorUrl, monitor, limitManager, 60, apiPort, accessToken);
170
+ await heartbeat.start();
171
+ logger.info('heartbeat service started - sending status every 60 seconds');
172
+ }
173
+ else {
174
+ logger.warn('no orchestrator URL configured - heartbeat disabled');
175
+ }
176
+ // setup job handling
177
+ const containerMgr = new ContainerManager();
178
+ await containerMgr.start();
179
+ const jobHandler = new JobHandler(p2p, containerMgr, limitManager, config.orchestratorUrl);
180
+ // initialize deployment executor whenever orchestrator is configured
181
+ // shell sessions and stats only need local docker access, not registration
182
+ let deploymentExecutor = null;
183
+ if (config.orchestratorUrl) {
184
+ deploymentExecutor = new DeploymentExecutor({
185
+ orchestratorUrl: config.orchestratorUrl,
186
+ apiKey
187
+ });
188
+ // discover any existing deployments from previous runs
189
+ await deploymentExecutor.discoverExistingDeployments();
190
+ logger.info('deployment executor initialized');
191
+ }
192
+ // start api server for exec/logs (with deployment executor if available)
193
+ const apiServer = new NodeAPIServer(containerMgr, deploymentExecutor || undefined, apiPort, accessToken);
194
+ await apiServer.start();
195
+ // listen for pending jobs from heartbeat
196
+ const processedJobs = new Set();
197
+ if (heartbeat) {
198
+ heartbeat.on('pending-jobs', (jobs) => {
199
+ logger.info({ count: jobs.length }, 'processing pending jobs from heartbeat');
200
+ for (const job of jobs) {
201
+ // skip if already processed
202
+ if (processedJobs.has(job.id)) {
203
+ continue;
204
+ }
205
+ processedJobs.add(job.id);
206
+ jobHandler.handleJob(job).catch(err => {
207
+ logger.error({ err, jobId: job.id }, 'failed to handle job from heartbeat');
208
+ });
209
+ }
210
+ });
211
+ // evict old entries instead of clearing everything
212
+ setInterval(() => {
213
+ // keep the set from growing unbounded but don't clear it wholesale
214
+ // which could cause duplicate processing
215
+ if (processedJobs.size > 10000) {
216
+ const entries = Array.from(processedJobs);
217
+ const toRemove = entries.slice(0, entries.length - 5000);
218
+ for (const id of toRemove) {
219
+ processedJobs.delete(id);
220
+ }
221
+ }
222
+ }, 5 * 60 * 1000);
223
+ }
224
+ // wire up earnings tracking
225
+ jobHandler.on('job-completed', ({ jobId, earnings }) => {
226
+ logger.info({ jobId, earnings }, 'earned from job');
227
+ stateManager.addEarnings(earnings);
228
+ stateManager.incrementCompleted();
229
+ });
230
+ jobHandler.on('job-failed', ({ jobId }) => {
231
+ logger.warn({ jobId }, 'job failed');
232
+ stateManager.incrementFailed();
233
+ });
234
+ // listen for jobs
235
+ p2p.on('job-request', async (job) => {
236
+ // only log non-sensitive job info (NOT env vars)
237
+ logger.info({
238
+ jobId: job.id,
239
+ image: job.image,
240
+ resources: job.resources
241
+ // intentionally NOT logging: env, userId (privacy)
242
+ }, 'got a job request');
243
+ const accepted = await jobHandler.handleJob(job);
244
+ if (accepted) {
245
+ logger.info({ jobId: job.id }, 'job accepted and running');
246
+ }
247
+ });
248
+ // new deployment system
249
+ let autoBidder = null;
250
+ let leaseHandler = null;
251
+ if (config.orchestratorUrl && registered && registrationResult) {
252
+ // use provider id from registration
253
+ const providerId = registrationResult.providerId;
254
+ // verify provider account is registered
255
+ try {
256
+ const providerRes = await fetch(`${config.orchestratorUrl}/api/v1/provider/status`, {
257
+ headers: {
258
+ 'Authorization': `Bearer ${apiKey}`
259
+ }
260
+ });
261
+ if (providerRes.ok) {
262
+ logger.info({ providerId }, 'provider account ready');
263
+ }
264
+ }
265
+ catch (err) {
266
+ logger.warn('failed to verify provider account');
267
+ }
268
+ // deployment executor already initialized above
269
+ // setup auto-bidder for competitive bidding
270
+ autoBidder = new AutoBidder({
271
+ nodeId,
272
+ providerId,
273
+ orchestratorUrl: config.orchestratorUrl,
274
+ apiKey, // pass the api key for authentication
275
+ pricingStrategy: {
276
+ cpuPricePerCore: 0.00017, // ~$0.01 per core per hour
277
+ memoryPricePerGb: 0.00008, // ~$0.005 per GB per hour
278
+ gpuPricePerUnit: 0.01, // ~$0.60 per gpu per hour
279
+ margin: 0.9 // bid 10% below cost to win
280
+ }
281
+ }, monitor);
282
+ autoBidder.start(15000); // check for orders every 15s
283
+ logger.info({ nodeId }, 'auto-bidder started - will bid on suitable orders');
284
+ // setup lease handler
285
+ if (!deploymentExecutor) {
286
+ throw new Error('deployment executor not initialized');
287
+ }
288
+ leaseHandler = new LeaseHandler({
289
+ nodeId,
290
+ providerId,
291
+ orchestratorUrl: config.orchestratorUrl,
292
+ apiKey
293
+ }, deploymentExecutor);
294
+ leaseHandler.start(10000); // check for leases every 10s
295
+ logger.info({ nodeId }, 'lease handler started - will execute assigned deployments');
296
+ // wire up shell session handling from p2p to deployment executor
297
+ p2p.on('shell-start', async (data) => {
298
+ const { sessionId, deploymentId, serviceName } = data;
299
+ logger.info({ sessionId, deploymentId, serviceName }, 'shell-start request received');
300
+ const success = await deploymentExecutor.startShellSession(sessionId, deploymentId, serviceName, (output) => {
301
+ // send output back to orchestrator via p2p
302
+ p2p.sendToOrchestrator({
303
+ type: 'shell-output',
304
+ data: { sessionId, output }
305
+ });
306
+ });
307
+ if (!success) {
308
+ logger.warn({ sessionId, deploymentId }, 'failed to start shell session');
309
+ }
310
+ });
311
+ p2p.on('shell-input', (data) => {
312
+ const { sessionId, input } = data;
313
+ deploymentExecutor.sendShellInput(sessionId, input);
314
+ });
315
+ p2p.on('shell-resize', (data) => {
316
+ const { sessionId, cols, rows } = data;
317
+ deploymentExecutor.resizeShell(sessionId, cols, rows);
318
+ });
319
+ p2p.on('shell-close', (data) => {
320
+ const { sessionId } = data;
321
+ deploymentExecutor.closeShellSession(sessionId);
322
+ });
323
+ // forward shell-closed events from executor back to orchestrator
324
+ deploymentExecutor.on('shell-closed', ({ sessionId }) => {
325
+ p2p.sendToOrchestrator({
326
+ type: 'shell-closed',
327
+ data: { sessionId }
328
+ });
329
+ });
330
+ logger.info('shell session handlers configured');
331
+ }
332
+ // graceful shutdown on both SIGINT and SIGTERM
333
+ const shutdown = async (signal) => {
334
+ logger.info({ signal }, 'shutting down...');
335
+ stateManager.setStopped();
336
+ if (autoBidder) {
337
+ autoBidder.stop();
338
+ }
339
+ if (leaseHandler) {
340
+ leaseHandler.stop();
341
+ }
342
+ if (heartbeat) {
343
+ await heartbeat.stop();
344
+ }
345
+ await apiServer.stop();
346
+ await containerMgr.stop();
347
+ await p2p.stop();
348
+ await monitor.stop();
349
+ process.exit(0);
350
+ };
351
+ process.on('SIGINT', () => shutdown('SIGINT'));
352
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
353
+ }
354
+ catch (err) {
355
+ logger.error({ err }, 'failed to start');
356
+ process.exit(1);
357
+ }
358
+ }
@@ -0,0 +1,50 @@
1
+ import { logger } from '../lib/logger.js';
2
+ import { ResourceMonitor } from '../lib/monitor.js';
3
+ import { stateManager } from '../lib/state.js';
4
+ export async function statusCommand() {
5
+ try {
6
+ const state = stateManager.getState();
7
+ const monitor = new ResourceMonitor();
8
+ await monitor.start();
9
+ const resources = await monitor.getAvailableResources();
10
+ console.log('\n📊 kova node status\n');
11
+ // node running status
12
+ if (state.isRunning) {
13
+ const uptime = stateManager.getUptime();
14
+ const hours = Math.floor(uptime / 1000 / 60 / 60);
15
+ const minutes = Math.floor((uptime / 1000 / 60) % 60);
16
+ console.log(`node status: 🟢 online (${hours}h ${minutes}m uptime)`);
17
+ console.log(`pid: ${state.pid}`);
18
+ }
19
+ else {
20
+ console.log('node status: 🔴 offline');
21
+ }
22
+ console.log('');
23
+ // resources
24
+ console.log(`cpu: ${resources.cpu.available.toFixed(1)} / ${resources.cpu.cores} cores available`);
25
+ console.log(`memory: ${resources.memory.available} / ${resources.memory.total} GB available`);
26
+ console.log(`network: ~${resources.network.bandwidth} Mbps`);
27
+ // check docker
28
+ const Docker = (await import('dockerode')).default;
29
+ const docker = new Docker();
30
+ try {
31
+ await docker.ping();
32
+ console.log('docker: ✅ running');
33
+ }
34
+ catch {
35
+ console.log('docker: ❌ not running');
36
+ }
37
+ // earnings summary
38
+ if (state.isRunning) {
39
+ console.log('');
40
+ console.log(`jobs completed: ${state.jobsCompleted}`);
41
+ console.log(`total earned: $${state.totalEarnings.toFixed(4)}`);
42
+ }
43
+ console.log('');
44
+ await monitor.stop();
45
+ }
46
+ catch (err) {
47
+ logger.error({ err }, 'status check failed');
48
+ process.exit(1);
49
+ }
50
+ }
@@ -0,0 +1,101 @@
1
+ // stop command - gracefully stops a running kova node
2
+ import { logger } from '../lib/logger.js';
3
+ import { stateManager } from '../lib/state.js';
4
+ // check if a process is running
5
+ function isProcessRunning(pid) {
6
+ try {
7
+ // sending signal 0 doesn't actually send a signal, just checks if process exists
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch (err) {
12
+ // process doesn't exist or we don't have permission
13
+ return err.code === 'EPERM';
14
+ }
15
+ }
16
+ // wait for a process to terminate with timeout
17
+ async function waitForProcessExit(pid, timeoutMs = 30000) {
18
+ const startTime = Date.now();
19
+ while (Date.now() - startTime < timeoutMs) {
20
+ if (!isProcessRunning(pid)) {
21
+ return true;
22
+ }
23
+ await new Promise(resolve => setTimeout(resolve, 500));
24
+ }
25
+ return false;
26
+ }
27
+ export async function stopCommand() {
28
+ logger.info('stopping kova node...');
29
+ // check if node is running by reading state
30
+ const state = stateManager.getState();
31
+ if (!state.isRunning || !state.pid) {
32
+ console.log('\nkova node is not running');
33
+ return;
34
+ }
35
+ const pid = state.pid;
36
+ // verify the process is actually running
37
+ if (!isProcessRunning(pid)) {
38
+ console.log('\nkova node is not running (stale state)');
39
+ stateManager.setStopped();
40
+ return;
41
+ }
42
+ console.log(`\nfound kova node running with pid ${pid}`);
43
+ console.log('sending graceful shutdown signal...');
44
+ try {
45
+ // send SIGINT for graceful shutdown (same as ctrl+c)
46
+ process.kill(pid, 'SIGINT');
47
+ console.log('waiting for node to stop...');
48
+ // wait up to 30 seconds for graceful shutdown
49
+ const stopped = await waitForProcessExit(pid, 30000);
50
+ if (stopped) {
51
+ console.log('\n✓ kova node stopped successfully');
52
+ // show final stats
53
+ const finalState = stateManager.getState();
54
+ console.log(`\nfinal stats:`);
55
+ console.log(` jobs completed: ${finalState.jobsCompleted}`);
56
+ console.log(` jobs failed: ${finalState.jobsFailed}`);
57
+ console.log(` total earnings: ${finalState.totalEarnings.toFixed(4)}`);
58
+ console.log(` active deployments: ${finalState.activeDeployments.length}`);
59
+ if (finalState.activeDeployments.length > 0) {
60
+ console.log('\nwarning: there were active deployments when node stopped');
61
+ console.log('these will be picked up again when node restarts');
62
+ }
63
+ }
64
+ else {
65
+ // graceful shutdown timed out, try harder
66
+ console.log('\ngraceful shutdown timed out, forcing termination...');
67
+ try {
68
+ process.kill(pid, 'SIGTERM');
69
+ // wait another 10 seconds
70
+ const forceStopped = await waitForProcessExit(pid, 10000);
71
+ if (forceStopped) {
72
+ console.log('✓ node terminated');
73
+ }
74
+ else {
75
+ // last resort
76
+ console.log('sending SIGKILL...');
77
+ process.kill(pid, 'SIGKILL');
78
+ await waitForProcessExit(pid, 5000);
79
+ console.log('✓ node killed');
80
+ }
81
+ }
82
+ catch (err) {
83
+ console.error('failed to terminate process');
84
+ }
85
+ // update state regardless
86
+ stateManager.setStopped();
87
+ }
88
+ }
89
+ catch (err) {
90
+ if (err.code === 'ESRCH') {
91
+ console.log('\nkova node already stopped');
92
+ stateManager.setStopped();
93
+ }
94
+ else if (err.code === 'EPERM') {
95
+ console.error('\n❌ permission denied - try running with sudo');
96
+ }
97
+ else {
98
+ console.error(`\n❌ failed to stop node: ${err.message}`);
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,87 @@
1
+ // shared cli helpers for api calls, auth, and output formatting
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ const CONFIG_DIR = join(homedir(), '.kova');
6
+ const AUTH_FILE = join(CONFIG_DIR, 'auth.json');
7
+ export function getApiUrl() {
8
+ return process.env.KOVA_API_URL || 'https://app.kovanetwork.com';
9
+ }
10
+ export function getAuthToken() {
11
+ try {
12
+ if (!existsSync(AUTH_FILE))
13
+ return null;
14
+ const data = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
15
+ return data.token || null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function saveAuthToken(token) {
22
+ if (!existsSync(CONFIG_DIR)) {
23
+ mkdirSync(CONFIG_DIR, { recursive: true });
24
+ }
25
+ writeFileSync(AUTH_FILE, JSON.stringify({ token, savedAt: new Date().toISOString() }));
26
+ }
27
+ export function clearAuthToken() {
28
+ try {
29
+ if (existsSync(AUTH_FILE)) {
30
+ writeFileSync(AUTH_FILE, '{}');
31
+ }
32
+ }
33
+ catch { }
34
+ }
35
+ export async function authFetch(path, options = {}) {
36
+ const token = getAuthToken();
37
+ const url = `${getApiUrl()}${path}`;
38
+ const headers = {
39
+ 'Content-Type': 'application/json',
40
+ ...(options.headers || {})
41
+ };
42
+ if (token) {
43
+ headers['Authorization'] = `Bearer ${token}`;
44
+ }
45
+ return fetch(url, { ...options, headers });
46
+ }
47
+ export function formatDate(dateStr) {
48
+ try {
49
+ const d = new Date(dateStr);
50
+ return d.toLocaleString();
51
+ }
52
+ catch {
53
+ return dateStr;
54
+ }
55
+ }
56
+ export function formatTable(rows) {
57
+ if (rows.length === 0)
58
+ return;
59
+ const keys = Object.keys(rows[0]);
60
+ const widths = {};
61
+ // calculate column widths
62
+ for (const key of keys) {
63
+ widths[key] = key.length;
64
+ for (const row of rows) {
65
+ const val = String(row[key] ?? '');
66
+ widths[key] = Math.max(widths[key], val.length);
67
+ }
68
+ }
69
+ // print header
70
+ const header = keys.map(k => k.padEnd(widths[k])).join(' ');
71
+ console.log(header);
72
+ console.log(keys.map(k => '-'.repeat(widths[k])).join(' '));
73
+ // print rows
74
+ for (const row of rows) {
75
+ const line = keys.map(k => String(row[k] ?? '').padEnd(widths[k])).join(' ');
76
+ console.log(line);
77
+ }
78
+ }
79
+ export function handleApiError(err) {
80
+ if (err.cause?.code === 'ECONNREFUSED') {
81
+ console.error('\ncannot connect to kova api. check your network or KOVA_API_URL setting.');
82
+ }
83
+ else {
84
+ console.error(`\nerror: ${err.message || 'unknown error'}`);
85
+ }
86
+ process.exit(1);
87
+ }