hackerrun 0.1.0 → 0.1.2

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,240 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { getPlatformToken } from '../lib/platform-auth.js';
5
+ import { PlatformClient } from '../lib/platform-client.js';
6
+ import {
7
+ isWireGuardInstalled,
8
+ isVPNUp,
9
+ getVPNStatus,
10
+ getOrCreateKeyPair,
11
+ getPublicKey,
12
+ writeWireGuardConfig,
13
+ vpnUp,
14
+ vpnDown,
15
+ testIPv6Connectivity,
16
+ VPNConfig,
17
+ } from '../lib/vpn.js';
18
+
19
+ // Default location for VPN gateway
20
+ const DEFAULT_LOCATION = 'eu-central-h1';
21
+
22
+ export function createVPNCommands() {
23
+ const vpnCmd = new Command('vpn');
24
+ vpnCmd.description('Manage VPN connection for IPv6 access to your apps');
25
+
26
+ // vpn status
27
+ vpnCmd
28
+ .command('status')
29
+ .description('Show VPN connection status')
30
+ .action(async () => {
31
+ try {
32
+ if (!isWireGuardInstalled()) {
33
+ console.log(chalk.yellow('\nWireGuard is not installed.\n'));
34
+ console.log('Install it with:');
35
+ console.log(' Ubuntu/Debian: sudo apt install wireguard');
36
+ console.log(' Fedora: sudo dnf install wireguard-tools');
37
+ console.log(' Arch: sudo pacman -S wireguard-tools');
38
+ console.log(' macOS: brew install wireguard-tools\n');
39
+ return;
40
+ }
41
+
42
+ const status = getVPNStatus();
43
+
44
+ console.log(chalk.cyan('\nVPN Status\n'));
45
+
46
+ if (status.connected) {
47
+ console.log(` Status: ${chalk.green('Connected')}`);
48
+ console.log(` Interface: ${status.interface}`);
49
+ if (status.endpoint) {
50
+ console.log(` Endpoint: ${status.endpoint}`);
51
+ }
52
+ if (status.latestHandshake) {
53
+ console.log(` Handshake: ${status.latestHandshake}`);
54
+ }
55
+ if (status.transferRx && status.transferTx) {
56
+ console.log(` Transfer: ${status.transferRx} received, ${status.transferTx} sent`);
57
+ }
58
+ } else {
59
+ console.log(` Status: ${chalk.yellow('Disconnected')}`);
60
+ }
61
+
62
+ // Show local public key if available
63
+ const publicKey = getPublicKey();
64
+ if (publicKey) {
65
+ console.log(` Public Key: ${chalk.dim(publicKey.substring(0, 20) + '...')}`);
66
+ }
67
+
68
+ console.log();
69
+ } catch (error) {
70
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
71
+ process.exit(1);
72
+ }
73
+ });
74
+
75
+ // vpn connect
76
+ vpnCmd
77
+ .command('connect')
78
+ .description('Establish VPN connection to gateway for IPv6 access')
79
+ .option('-l, --location <location>', 'Gateway location', DEFAULT_LOCATION)
80
+ .action(async (options) => {
81
+ try {
82
+ // Check WireGuard is installed
83
+ if (!isWireGuardInstalled()) {
84
+ console.error(chalk.red('\nWireGuard is not installed.\n'));
85
+ console.log('Install it with:');
86
+ console.log(' Ubuntu/Debian: sudo apt install wireguard');
87
+ console.log(' Fedora: sudo dnf install wireguard-tools');
88
+ console.log(' Arch: sudo pacman -S wireguard-tools');
89
+ console.log(' macOS: brew install wireguard-tools\n');
90
+ process.exit(1);
91
+ }
92
+
93
+ // Check if already connected
94
+ if (isVPNUp()) {
95
+ console.log(chalk.green('\nVPN is already connected.\n'));
96
+ const status = getVPNStatus();
97
+ if (status.endpoint) {
98
+ console.log(` Endpoint: ${status.endpoint}`);
99
+ }
100
+ console.log();
101
+ return;
102
+ }
103
+
104
+ const platformToken = getPlatformToken();
105
+ const platformClient = new PlatformClient(platformToken);
106
+
107
+ console.log(chalk.cyan('\nEstablishing VPN connection...\n'));
108
+
109
+ // Get or create keypair
110
+ const spinner = ora('Preparing WireGuard keypair...').start();
111
+ const { publicKey, isNew } = getOrCreateKeyPair();
112
+ if (isNew) {
113
+ spinner.succeed('Generated new WireGuard keypair');
114
+ } else {
115
+ spinner.succeed('Using existing WireGuard keypair');
116
+ }
117
+
118
+ // Register with platform and get config
119
+ spinner.start('Registering with gateway...');
120
+ const vpnConfigResponse = await platformClient.registerVPNPeer(publicKey, options.location);
121
+ spinner.succeed('Registered with gateway');
122
+
123
+ // Build full VPN config
124
+ const { privateKey } = getOrCreateKeyPair();
125
+ const vpnConfig: VPNConfig = {
126
+ privateKey,
127
+ publicKey,
128
+ address: vpnConfigResponse.address,
129
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
130
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
131
+ allowedIPs: vpnConfigResponse.allowedIPs,
132
+ };
133
+
134
+ // Write config file
135
+ spinner.start('Writing WireGuard configuration...');
136
+ writeWireGuardConfig(vpnConfig);
137
+ spinner.succeed('WireGuard configuration written');
138
+
139
+ // Bring up VPN
140
+ spinner.start('Starting VPN tunnel (requires sudo)...');
141
+ spinner.stop();
142
+ console.log(chalk.dim(' Starting VPN tunnel (requires sudo)...'));
143
+ vpnUp();
144
+ console.log(chalk.green(' ✓ VPN tunnel started'));
145
+
146
+ // Verify connection
147
+ spinner.start('Verifying connection...');
148
+ // Give it a moment to establish
149
+ await new Promise(r => setTimeout(r, 1000));
150
+
151
+ const status = getVPNStatus();
152
+ if (status.connected) {
153
+ spinner.succeed('VPN connected successfully');
154
+ } else {
155
+ spinner.warn('VPN interface is up but connection not verified');
156
+ }
157
+
158
+ console.log(chalk.green('\n✓ VPN connected!\n'));
159
+ console.log(` Your IPv6: ${chalk.bold(vpnConfigResponse.address.split('/')[0])}`);
160
+ console.log(` Gateway: ${chalk.bold(vpnConfigResponse.gatewayEndpoint)}`);
161
+ console.log();
162
+ console.log(chalk.dim('You now have IPv6 connectivity to your app VMs.'));
163
+ console.log(chalk.dim(`Run ${chalk.bold('hackerrun vpn disconnect')} to disconnect.\n`));
164
+
165
+ } catch (error) {
166
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
167
+ process.exit(1);
168
+ }
169
+ });
170
+
171
+ // vpn disconnect
172
+ vpnCmd
173
+ .command('disconnect')
174
+ .description('Disconnect VPN')
175
+ .action(async () => {
176
+ try {
177
+ if (!isVPNUp()) {
178
+ console.log(chalk.yellow('\nVPN is not connected.\n'));
179
+ return;
180
+ }
181
+
182
+ console.log(chalk.cyan('\nDisconnecting VPN...\n'));
183
+ console.log(chalk.dim(' Stopping VPN tunnel (requires sudo)...'));
184
+ vpnDown();
185
+ console.log(chalk.green(' ✓ VPN tunnel stopped'));
186
+
187
+ console.log(chalk.green('\n✓ VPN disconnected.\n'));
188
+
189
+ } catch (error) {
190
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
191
+ process.exit(1);
192
+ }
193
+ });
194
+
195
+ // vpn test
196
+ vpnCmd
197
+ .command('test')
198
+ .description('Test IPv6 connectivity to an app VM')
199
+ .argument('<ipv6>', 'IPv6 address to test')
200
+ .action(async (ipv6: string) => {
201
+ try {
202
+ console.log(chalk.cyan(`\nTesting connectivity to ${ipv6}...\n`));
203
+
204
+ const spinner = ora('Testing direct IPv6...').start();
205
+ const directOk = testIPv6Connectivity(ipv6, 3);
206
+
207
+ if (directOk) {
208
+ spinner.succeed('Direct IPv6 connectivity works');
209
+ console.log(chalk.green('\n✓ You can reach this address directly.\n'));
210
+ } else {
211
+ spinner.fail('Direct IPv6 not available');
212
+
213
+ if (isVPNUp()) {
214
+ spinner.start('VPN is up, testing through VPN...');
215
+ const vpnOk = testIPv6Connectivity(ipv6, 5);
216
+ if (vpnOk) {
217
+ spinner.succeed('Connectivity works through VPN');
218
+ console.log(chalk.green('\n✓ You can reach this address via VPN.\n'));
219
+ } else {
220
+ spinner.fail('Cannot reach address even through VPN');
221
+ console.log(chalk.red('\n✗ Cannot reach this address.\n'));
222
+ console.log('Possible issues:');
223
+ console.log(' - The target VM may be down');
224
+ console.log(' - VPN routing may be misconfigured');
225
+ console.log(' - Firewall may be blocking traffic\n');
226
+ }
227
+ } else {
228
+ console.log(chalk.yellow('\n✗ Cannot reach this address directly.\n'));
229
+ console.log(`Run ${chalk.bold('hackerrun vpn connect')} to establish VPN and try again.\n`);
230
+ }
231
+ }
232
+
233
+ } catch (error) {
234
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
235
+ process.exit(1);
236
+ }
237
+ });
238
+
239
+ return vpnCmd;
240
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { createLogsCommand } from './commands/logs.js';
7
7
  import { createEnvCommand } from './commands/env.js';
8
8
  import { createConnectCommand, createDisconnectCommand } from './commands/connect.js';
9
9
  import { createBuildsCommand } from './commands/builds.js';
10
+ import { createVPNCommands } from './commands/vpn.js';
11
+ import { createScaleCommand } from './commands/scale.js';
10
12
 
11
13
  const program = new Command();
12
14
 
@@ -31,6 +33,12 @@ program.addCommand(createDisconnectCommand());
31
33
  program.addCommand(createEnvCommand());
32
34
  program.addCommand(createBuildsCommand());
33
35
 
36
+ // Register VPN command
37
+ program.addCommand(createVPNCommands());
38
+
39
+ // Register scale command
40
+ program.addCommand(createScaleCommand());
41
+
34
42
  const { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
35
43
  program.addCommand(appsCmd);
36
44
  program.addCommand(nodesCmd);
@@ -1,5 +1,6 @@
1
1
  import { AppCluster, VMNode, PlatformClient } from './platform-client.js';
2
- import { SSHCertManager } from './ssh-cert.js';
2
+ import { SSHCertManager, testIPv6Connectivity } from './ssh-cert.js';
3
+ import { createTunnel, killTunnel, TunnelInfo } from './gateway-tunnel.js';
3
4
  import { execSync } from 'child_process';
4
5
  import ora from 'ora';
5
6
  import chalk from 'chalk';
@@ -57,8 +58,8 @@ export class ClusterManager {
57
58
  async initializeCluster(options: ClusterInitOptions): Promise<AppCluster> {
58
59
  const { appName, location, vmSize, storageSize, bootImage } = options;
59
60
 
60
- // Generate VM name
61
- const vmName = `${appName}-vm-${this.generateId()}`;
61
+ // First node is always appName-1
62
+ const vmName = `${appName}-1`;
62
63
 
63
64
  let spinner = ora(`Creating VM '${vmName}' in ${location}...`).start();
64
65
 
@@ -125,12 +126,19 @@ export class ClusterManager {
125
126
  spinner.text = 'Installing Docker and Uncloud...';
126
127
  spinner.stop();
127
128
  console.log(chalk.cyan('\nInitializing uncloud (this may take a few minutes)...'));
128
- await this.initializeUncloud(vmWithIp.ip6!, appName);
129
+ await this.initializeUncloud(vmWithIp.ip6!, appName, gateway?.ipv4);
129
130
 
130
131
  spinner = ora('Configuring Docker for NAT64...').start();
131
132
 
132
133
  // Step 3: Configure Docker for NAT64
133
- await this.configureDockerNAT64(vmWithIp.ip6!);
134
+ await this.configureDockerNAT64(vmWithIp.ip6!, gateway?.ipv4);
135
+
136
+ // Wait for uncloud services to be ready after Docker restart
137
+ spinner.text = 'Waiting for uncloud services to be ready...';
138
+ await this.waitForUncloudReady(vmWithIp.ip6!, gateway?.ipv4);
139
+
140
+ // Clean up SSH sessions from initialization to get fresh state for deploy
141
+ this.sshCertManager.cleanupAll();
134
142
 
135
143
  spinner.succeed(chalk.green(`Cluster initialized successfully`));
136
144
 
@@ -150,16 +158,18 @@ export class ClusterManager {
150
158
  throw new Error(`App '${appName}' not found`);
151
159
  }
152
160
 
153
- const primaryNode = await this.platformClient.getPrimaryNode(appName);
154
- if (!primaryNode || !primaryNode.ipv6) {
155
- throw new Error(`Primary node not found or has no IPv6 address`);
161
+ // Find any existing node to use as join target (all nodes are equal in uncloud)
162
+ const existingNode = cluster.nodes.find(n => n.ipv6);
163
+ if (!existingNode || !existingNode.ipv6) {
164
+ throw new Error(`No existing node with IPv6 address found to join`);
156
165
  }
157
166
 
158
167
  // Get gateway info to get the private subnet ID for NAT64 routing
159
168
  const gateway = await this.platformClient.getGateway(cluster.location);
160
169
  const privateSubnetId = gateway?.subnetId;
161
170
 
162
- const vmName = `${appName}-vm-${this.generateId()}`;
171
+ // Generate sequential node name: appName-1, appName-2, etc.
172
+ const vmName = this.generateNodeName(appName, cluster.nodes);
163
173
 
164
174
  let spinner = ora(`Adding node '${vmName}' to cluster...`).start();
165
175
 
@@ -193,16 +203,16 @@ export class ClusterManager {
193
203
  spinner.text = 'Configuring VM...';
194
204
  await this.platformClient.setupVM(vmWithIp.ip6!, cluster.location, appName);
195
205
 
196
- // Step 2: Get join token from primary and join cluster
206
+ // Step 2: Get join token from existing node and join cluster
197
207
  spinner.text = 'Joining uncloud cluster...';
198
208
  spinner.stop();
199
209
  console.log(chalk.cyan('\nJoining uncloud cluster...'));
200
- await this.joinUncloudCluster(vmWithIp.ip6!, primaryNode.ipv6, appName);
210
+ await this.joinUncloudCluster(vmWithIp.ip6!, existingNode.ipv6, appName);
201
211
 
202
212
  spinner = ora('Configuring Docker for NAT64...').start();
203
213
 
204
214
  // Step 3: Configure Docker for NAT64
205
- await this.configureDockerNAT64(vmWithIp.ip6!);
215
+ await this.configureDockerNAT64(vmWithIp.ip6!, gateway?.ipv4);
206
216
 
207
217
  spinner.succeed(chalk.green(`Node '${vmName}' added successfully`));
208
218
 
@@ -231,20 +241,81 @@ export class ClusterManager {
231
241
  * Before running uc, we get an SSH certificate from the platform
232
242
  * and add it to the ssh-agent. This allows uc to authenticate
233
243
  * since the VM only accepts platform SSH key or signed certificates.
244
+ *
245
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
234
246
  */
235
- private async initializeUncloud(vmIp: string, contextName: string): Promise<void> {
247
+ private async initializeUncloud(vmIp: string, contextName: string, gatewayIp?: string): Promise<void> {
248
+ let tunnel: TunnelInfo | null = null;
249
+ let targetHost: string = vmIp;
250
+
236
251
  try {
237
252
  // Get SSH certificate and add to agent (required for auth)
238
253
  // The VM was created with platform SSH key, so we need a certificate
239
254
  await this.sshCertManager.getSession(contextName, vmIp);
240
255
 
256
+ // Test if we can reach the VM directly via IPv6
257
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
258
+
259
+ if (!canConnectDirect) {
260
+ if (!gatewayIp) {
261
+ throw new Error('No direct IPv6 connectivity and no gateway available');
262
+ }
263
+
264
+ console.log(chalk.dim('No direct IPv6 connectivity, tunneling through gateway...'));
265
+
266
+ // Create tunnel through gateway
267
+ tunnel = await createTunnel(vmIp, gatewayIp);
268
+ targetHost = `localhost:${tunnel.localPort}`;
269
+ }
270
+
241
271
  // uc machine init connects to the VM and installs Docker + uncloud daemon
242
- execSync(`uc machine init -c "${contextName}" --no-dns root@${vmIp}`, {
243
- stdio: 'inherit',
244
- timeout: 600000, // 10 min timeout
245
- });
272
+ // Sometimes Caddy deployment fails with "machine is not ready" - we retry in that case
273
+ let initSucceeded = false;
274
+
275
+ try {
276
+ execSync(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
277
+ stdio: 'inherit',
278
+ timeout: 600000, // 10 min timeout
279
+ });
280
+ initSucceeded = true;
281
+ } catch (error: any) {
282
+ // uc machine init failed - but uncloud might still be installed, just Caddy failed
283
+ // We'll try to deploy Caddy separately below
284
+ console.log(chalk.yellow('\nInitial setup had errors, will attempt to recover...'));
285
+ }
286
+
287
+ // If init failed, retry Caddy deployment (uncloud daemon may need time to initialize)
288
+ if (!initSucceeded) {
289
+ const maxCaddyAttempts = 5;
290
+
291
+ for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
292
+ console.log(chalk.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
293
+ await new Promise(resolve => setTimeout(resolve, 10000));
294
+
295
+ try {
296
+ execSync(`yes | uc -c "${contextName}" caddy deploy`, {
297
+ stdio: 'inherit',
298
+ timeout: 120000,
299
+ });
300
+ console.log(chalk.green('Caddy deployed successfully'));
301
+ break;
302
+ } catch (caddyError: any) {
303
+ if (attempt >= maxCaddyAttempts) {
304
+ throw new Error(`Caddy deployment failed after ${maxCaddyAttempts} attempts. The machine may need more time to initialize.`);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ // If we got here without errors, uncloud is working
311
+ console.log(chalk.dim('Uncloud initialization complete.'));
246
312
  } catch (error) {
247
313
  throw new Error(`Failed to initialize uncloud: ${(error as Error).message}`);
314
+ } finally {
315
+ // Clean up tunnel if we created one
316
+ if (tunnel) {
317
+ killTunnel(tunnel);
318
+ }
248
319
  }
249
320
  }
250
321
 
@@ -288,8 +359,10 @@ export class ClusterManager {
288
359
  * 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
289
360
  * 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
290
361
  * 3. Applications then use IPv6 (NAT64) which works via the gateway
362
+ *
363
+ * If direct IPv6 connectivity is not available, tunnels through the gateway.
291
364
  */
292
- private async configureDockerNAT64(vmIp: string): Promise<void> {
365
+ private async configureDockerNAT64(vmIp: string, gatewayIp?: string): Promise<void> {
293
366
  const setupScript = `#!/bin/bash
294
367
  set -e
295
368
 
@@ -355,10 +428,24 @@ systemctl start docker-ipv6-nat64
355
428
  echo "Docker NAT64 configuration complete"
356
429
  `;
357
430
 
431
+ let tunnel: TunnelInfo | null = null;
432
+ let sshHost: string = vmIp;
433
+ let sshPortArgs: string = '';
434
+
358
435
  try {
436
+ // Test if we can reach the VM directly via IPv6
437
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
438
+
439
+ if (!canConnectDirect && gatewayIp) {
440
+ // Create tunnel through gateway
441
+ tunnel = await createTunnel(vmIp, gatewayIp);
442
+ sshHost = 'localhost';
443
+ sshPortArgs = `-p ${tunnel.localPort}`;
444
+ }
445
+
359
446
  // Use SSH to run the script on the VM
360
447
  // After platform setup, SSH certificate auth is available
361
- execSync(`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${vmIp} 'bash -s' << 'REMOTESCRIPT'
448
+ execSync(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
362
449
  ${setupScript}
363
450
  REMOTESCRIPT`, {
364
451
  stdio: 'inherit',
@@ -366,6 +453,10 @@ REMOTESCRIPT`, {
366
453
  });
367
454
  } catch (error) {
368
455
  throw new Error(`Failed to configure Docker for NAT64: ${(error as Error).message}`);
456
+ } finally {
457
+ if (tunnel) {
458
+ killTunnel(tunnel);
459
+ }
369
460
  }
370
461
  }
371
462
 
@@ -413,7 +504,71 @@ REMOTESCRIPT`, {
413
504
  }
414
505
 
415
506
  /**
416
- * Generate a random ID
507
+ * Wait for uncloud services to be ready on the VM
508
+ * Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
509
+ * This is critical for first deploy - push will fail if unregistry isn't ready
510
+ */
511
+ private async waitForUncloudReady(vmIp: string, gatewayIp?: string): Promise<void> {
512
+ const maxAttempts = 30; // 30 attempts * 2s = 60s max wait
513
+ const checkInterval = 2000; // 2 seconds between checks
514
+
515
+ // Build SSH command with ProxyJump if needed
516
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
517
+ const proxyJump = (!canConnectDirect && gatewayIp) ? `-J root@${gatewayIp}` : '';
518
+ const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
519
+
520
+ console.log(chalk.dim(' Waiting for uncloud services...'));
521
+
522
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
523
+ try {
524
+ // Check that unregistry is accepting connections
525
+ // unregistry runs at 10.210.0.1:5000 (Docker gateway IP)
526
+ const result = execSync(
527
+ `${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
528
+ { encoding: 'utf-8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] }
529
+ ).trim();
530
+
531
+ if (result.includes('ready')) {
532
+ console.log(chalk.dim(' Uncloud services ready'));
533
+ return;
534
+ }
535
+ } catch (error) {
536
+ // Ignore errors, will retry
537
+ }
538
+
539
+ if (attempt % 5 === 0) {
540
+ console.log(chalk.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
541
+ }
542
+
543
+ if (attempt < maxAttempts) {
544
+ await this.sleep(checkInterval);
545
+ }
546
+ }
547
+
548
+ // If we get here, services didn't become ready in time
549
+ console.log(chalk.yellow(' Warning: Timeout waiting for unregistry, continuing anyway...'));
550
+ }
551
+
552
+ /**
553
+ * Generate a sequential node name: appName-1, appName-2, etc.
554
+ * Finds the next available number by checking existing node names
555
+ */
556
+ private generateNodeName(appName: string, existingNodes: VMNode[]): string {
557
+ // Extract existing numbers from node names
558
+ const existingNumbers = existingNodes
559
+ .map(n => {
560
+ const match = n.name.match(new RegExp(`^${appName}-(\\d+)$`));
561
+ return match ? parseInt(match[1], 10) : 0;
562
+ })
563
+ .filter(n => n > 0);
564
+
565
+ // Find the next available number
566
+ const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
567
+ return `${appName}-${maxNumber + 1}`;
568
+ }
569
+
570
+ /**
571
+ * Generate a random ID (used for legacy naming or fallback)
417
572
  */
418
573
  private generateId(): string {
419
574
  return Math.random().toString(36).substring(2, 9);