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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hackerrun",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI tool to create and manage VMs using Ubicloud API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -238,10 +238,20 @@ export function createAppCommands() {
238
238
  await platformClient.deleteVM(app.location, node.name);
239
239
  }
240
240
 
241
- // Remove app from backend
241
+ // Remove app from backend (cascades to delete routes)
242
242
  spinner.text = 'Removing app from database...';
243
+ const appLocation = app.location; // Store before deletion
243
244
  await platformClient.deleteApp(appName);
244
245
 
246
+ // Sync gateway to remove stale route from Caddyfile
247
+ spinner.text = 'Syncing gateway routes...';
248
+ try {
249
+ await platformClient.syncGatewayRoutes(appLocation);
250
+ } catch (error) {
251
+ // Non-fatal: gateway sync failure shouldn't block destroy
252
+ console.log(chalk.dim(` Warning: Could not sync gateway: ${(error as Error).message}`));
253
+ }
254
+
245
255
  // Remove uncloud context from local config
246
256
  spinner.text = 'Cleaning up local configuration...';
247
257
  const contextName = app.uncloudContext || appName;
@@ -369,17 +379,31 @@ export function createAppCommands() {
369
379
  const spinner = ora(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
370
380
 
371
381
  try {
372
- const updatedApp = await platformClient.renameDomain(appName, newDomain);
373
- spinner.succeed(chalk.green(`Domain changed successfully`));
382
+ let updatedApp = app;
383
+
384
+ // Only call rename if domain is actually changing
385
+ if (oldDomain !== newDomain) {
386
+ updatedApp = await platformClient.renameDomain(appName, newDomain);
387
+ spinner.succeed(chalk.green(`Domain changed successfully`));
388
+ } else {
389
+ spinner.succeed(chalk.green(`Domain already set to '${newDomain}'`));
390
+ }
391
+
392
+ // Ensure route exists (create if missing, update if exists)
393
+ const primaryNode = app.nodes.find(n => n.isPrimary);
394
+ if (primaryNode?.ipv6) {
395
+ spinner.start('Ensuring route exists...');
396
+ await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
397
+ spinner.succeed(chalk.green('Route registered'));
398
+ }
374
399
 
375
- // Sync gateway routes with new domain
400
+ // Sync gateway routes
376
401
  spinner.start('Syncing gateway routes...');
377
402
  await platformClient.syncGatewayRoutes(app.location);
378
403
  spinner.succeed(chalk.green('Gateway routes synced'));
379
404
 
380
405
  console.log(`\n App: ${updatedApp.appName}`);
381
- console.log(` Old domain: https://${oldDomain}.hackerrun.app`);
382
- console.log(` New domain: https://${updatedApp.domainName}.hackerrun.app\n`);
406
+ console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app\n`);
383
407
  } catch (error) {
384
408
  spinner.fail(chalk.red('Failed to change domain'));
385
409
  throw error;
@@ -78,6 +78,7 @@ export function createConnectCommand() {
78
78
  choices: [
79
79
  { name: 'Change repository', value: 'change' },
80
80
  { name: 'Keep current connection', value: 'keep' },
81
+ { name: 'Reconnect (delete and re-setup)', value: 'reconnect' },
81
82
  ],
82
83
  });
83
84
 
@@ -85,6 +86,13 @@ export function createConnectCommand() {
85
86
  console.log(chalk.green('\nConnection unchanged.\n'));
86
87
  return;
87
88
  }
89
+
90
+ if (action === 'reconnect') {
91
+ const disconnectSpinner = ora('Disconnecting existing repository...').start();
92
+ await platformClient.disconnectRepo(appName);
93
+ disconnectSpinner.succeed('Existing connection removed');
94
+ console.log(chalk.cyan('\nReconnecting...\n'));
95
+ }
88
96
  }
89
97
 
90
98
  // Check for GitHub App installation
@@ -118,7 +126,51 @@ export function createConnectCommand() {
118
126
 
119
127
  // Fetch accessible repos
120
128
  const repoSpinner = ora('Fetching accessible repositories...').start();
121
- const repos = await platformClient.listAccessibleRepos();
129
+ let repos: Awaited<ReturnType<typeof platformClient.listAccessibleRepos>>;
130
+ try {
131
+ repos = await platformClient.listAccessibleRepos();
132
+ } catch (error: any) {
133
+ repoSpinner.stop();
134
+ // Check if this is a stale installation error (GitHub App was uninstalled)
135
+ const isStaleInstallation =
136
+ error.message.includes('create-an-installation-access-token') ||
137
+ error.message.includes('Bad credentials') ||
138
+ (error.message.includes('installation') && error.message.includes('Not Found'));
139
+
140
+ if (isStaleInstallation) {
141
+ console.log(chalk.yellow('\nGitHub App installation is stale or was removed from GitHub.'));
142
+ console.log(chalk.cyan('Clearing stale record and prompting for reinstallation...\n'));
143
+
144
+ await platformClient.deleteGitHubInstallation();
145
+
146
+ // Re-run installation flow
147
+ console.log(chalk.cyan('Please reinstall the HackerRun GitHub App:\n'));
148
+ const spinner = ora('Initiating GitHub connection...').start();
149
+ const flow = await platformClient.initiateGitHubConnect();
150
+ spinner.succeed('GitHub connection initiated');
151
+
152
+ console.log(chalk.cyan('\nPlease complete the following steps:\n'));
153
+ console.log(` 1. Visit: ${chalk.bold.blue(flow.authUrl)}`);
154
+ console.log(` 2. Install the HackerRun app on your account`);
155
+ console.log(` 3. Select the repositories you want to access\n`);
156
+
157
+ const pollSpinner = ora('Waiting for GitHub authorization...').start();
158
+ try {
159
+ await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
160
+ pollSpinner.succeed('GitHub App installed');
161
+ } catch (pollError) {
162
+ pollSpinner.fail((pollError as Error).message);
163
+ process.exit(1);
164
+ }
165
+
166
+ // Retry fetching repos
167
+ const retrySpinner = ora('Fetching accessible repositories...').start();
168
+ repos = await platformClient.listAccessibleRepos();
169
+ retrySpinner.stop();
170
+ } else {
171
+ throw error;
172
+ }
173
+ }
122
174
  repoSpinner.stop();
123
175
 
124
176
  if (repos.length === 0) {
@@ -85,19 +85,23 @@ export function createDeployCommand() {
85
85
  .option('-i, --image <image>', 'Boot image')
86
86
  .option('--build-token <token>', 'Build token for CI/CD (bypasses normal auth)')
87
87
  .action(async (options) => {
88
+ const deployStartTime = Date.now();
89
+
88
90
  try {
89
91
  // Check platform compatibility
90
92
  PlatformDetector.ensureSupported();
91
93
 
92
- // Check uncloud CLI is installed (offers to auto-install if missing)
93
- await UncloudManager.ensureInstalled();
94
-
95
94
  // Determine authentication mode
96
95
  // CI/CD mode: use build token if provided
97
96
  // Interactive mode: use user's platform token
98
- let platformToken: string;
99
97
  const isCIBuild = !!options.buildToken;
100
98
 
99
+ // Check uncloud CLI is installed
100
+ // In CI/CD mode, fail fast without interactive prompt (build VM should have it pre-installed)
101
+ // In interactive mode, offer to auto-install if missing
102
+ await UncloudManager.ensureInstalled({ nonInteractive: isCIBuild });
103
+
104
+ let platformToken: string;
101
105
  if (isCIBuild) {
102
106
  platformToken = options.buildToken;
103
107
  } else {
@@ -123,10 +127,18 @@ export function createDeployCommand() {
123
127
  let cluster = await platformClient.getApp(appName);
124
128
  let isFirstDeploy = false;
125
129
 
126
- if (!cluster) {
130
+ // App exists but has no nodes - this happens when app was created with
131
+ // metadataOnly (connect-before-deploy flow). Treat it like first deploy.
132
+ const needsInfrastructure = !cluster || cluster.nodes.length === 0;
133
+
134
+ if (needsInfrastructure) {
127
135
  // First deployment - create infrastructure
128
136
  isFirstDeploy = true;
129
- console.log(chalk.yellow('First deployment - creating infrastructure...\n'));
137
+ if (cluster && cluster.nodes.length === 0) {
138
+ console.log(chalk.yellow('App exists but has no infrastructure - creating VMs...\n'));
139
+ } else {
140
+ console.log(chalk.yellow('First deployment - creating infrastructure...\n'));
141
+ }
130
142
 
131
143
  // Initialize cluster (saves to platform and returns with domainName)
132
144
  cluster = await clusterManager.initializeCluster({
@@ -163,30 +175,87 @@ export function createDeployCommand() {
163
175
  console.log(chalk.cyan('\nRunning deployment...\n'));
164
176
 
165
177
  try {
166
- // Run uc deploy using ssh+cli:// connector
167
- await uncloudRunner.deploy(appName, process.cwd());
178
+ // Run uc deploy with automatic retry for connection failures
179
+ // This handles tunnel instability when going through gateway
180
+ const maxDeployAttempts = 3;
181
+ let lastError: Error | null = null;
182
+
183
+ for (let attempt = 1; attempt <= maxDeployAttempts; attempt++) {
184
+ try {
185
+ await uncloudRunner.deploy(appName, process.cwd());
186
+ lastError = null;
187
+ break; // Success, exit retry loop
188
+ } catch (error) {
189
+ lastError = error as Error;
190
+ const errorMsg = lastError.message.toLowerCase();
191
+
192
+ // Check if this is a retryable error
193
+ // When using gateway tunnel, connection issues manifest as "exit code 1"
194
+ // because spawnSync with stdio:inherit doesn't capture the actual error
195
+ const isRetryableError =
196
+ errorMsg.includes('connection reset') ||
197
+ errorMsg.includes('connection refused') ||
198
+ errorMsg.includes('broken pipe') ||
199
+ errorMsg.includes('network is unreachable') ||
200
+ errorMsg.includes('context deadline exceeded') ||
201
+ errorMsg.includes('exit code 1'); // Generic failure, often connection-related
202
+
203
+ if (isRetryableError && attempt < maxDeployAttempts) {
204
+ console.log(chalk.yellow(`\nDeploy failed, retrying (attempt ${attempt + 1}/${maxDeployAttempts})...\n`));
205
+ // Clean up current tunnel/session before retry
206
+ uncloudRunner.cleanup();
207
+ // Brief pause before retry
208
+ await new Promise(r => setTimeout(r, 3000));
209
+ } else {
210
+ // Non-retryable error or max attempts reached
211
+ throw lastError;
212
+ }
213
+ }
214
+ }
168
215
 
169
216
  console.log(chalk.green('\nApp deployed successfully!'));
170
217
 
171
218
  // Register route for this app (maps domain to VM IPv6, port 80 for HTTP)
219
+ // Uses retry logic since platform API may be temporarily unavailable after long deploys
172
220
  if (cluster.domainName) {
173
221
  const spinner = ora('Registering route...').start();
174
- try {
175
- const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
176
- spinner.succeed(chalk.green(`Route registered: ${route.fullUrl}`));
177
-
178
- // Sync gateway Caddy config with all routes
179
- spinner.start('Syncing gateway routes...');
180
- await platformClient.syncGatewayRoutes(cluster.location);
181
- spinner.succeed(chalk.green('Gateway routes synced'));
182
- } catch (error) {
183
- spinner.warn(chalk.yellow(`Could not register route: ${(error as Error).message}`));
222
+ let success = false;
223
+
224
+ for (let attempt = 1; attempt <= 5; attempt++) {
225
+ try {
226
+ if (attempt > 1) {
227
+ spinner.text = `Registering route (attempt ${attempt}/5)...`;
228
+ await new Promise(r => setTimeout(r, 2000));
229
+ }
230
+ const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
231
+ spinner.succeed(chalk.green(`Route registered: ${route.fullUrl}`));
232
+
233
+ // Sync gateway Caddy config with all routes
234
+ spinner.start('Syncing gateway routes...');
235
+ await platformClient.syncGatewayRoutes(cluster.location);
236
+ spinner.succeed(chalk.green('Gateway routes synced'));
237
+ success = true;
238
+ break;
239
+ } catch (error) {
240
+ if (attempt === 5) {
241
+ spinner.warn(chalk.yellow(`Could not register route: ${(error as Error).message}`));
242
+ console.log(chalk.dim(` Run 'hackerrun domain --app ${appName} ${cluster.domainName}' to retry`));
243
+ }
244
+ }
184
245
  }
185
246
  }
186
247
 
187
248
  // Update last deployed timestamp on backend
188
249
  await platformClient.updateLastDeployed(appName);
189
250
 
251
+ // Calculate deploy time
252
+ const deployDurationMs = Date.now() - deployStartTime;
253
+ const deployMinutes = Math.floor(deployDurationMs / 60000);
254
+ const deploySeconds = Math.floor((deployDurationMs % 60000) / 1000);
255
+ const deployTimeStr = deployMinutes > 0
256
+ ? `${deployMinutes}m ${deploySeconds}s`
257
+ : `${deploySeconds}s`;
258
+
190
259
  // Show summary
191
260
  console.log(chalk.cyan('\nDeployment Summary:\n'));
192
261
  console.log(` App Name: ${chalk.bold(appName)}`);
@@ -194,6 +263,7 @@ export function createDeployCommand() {
194
263
  console.log(` URL: ${chalk.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
195
264
  console.log(` Location: ${cluster.location}`);
196
265
  console.log(` Nodes: ${cluster.nodes.length}`);
266
+ console.log(` Deploy Time: ${chalk.bold(deployTimeStr)}`);
197
267
 
198
268
  console.log(chalk.cyan('\nInfrastructure:\n'));
199
269
  cluster.nodes.forEach((node, index) => {
@@ -0,0 +1,231 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { confirm } from '@inquirer/prompts';
5
+ import { getPlatformToken } from '../lib/platform-auth.js';
6
+ import { PlatformClient } from '../lib/platform-client.js';
7
+ import { ClusterManager } from '../lib/cluster.js';
8
+ import { getAppName } from '../lib/app-config.js';
9
+ import { UncloudRunner } from '../lib/uncloud-runner.js';
10
+
11
+ const DEFAULT_VM_SIZE = 'standard-2';
12
+ const DEFAULT_BOOT_IMAGE = 'ubuntu-noble';
13
+
14
+ export function createScaleCommand() {
15
+ const cmd = new Command('scale');
16
+ cmd
17
+ .description('Scale an app by adding or removing nodes')
18
+ .argument('[count]', 'Target node count, or +N/-N to add/remove nodes')
19
+ .option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
20
+ .option('--size <size>', `VM size for new nodes (default: ${DEFAULT_VM_SIZE})`)
21
+ .option('--force', 'Skip confirmation when removing nodes')
22
+ .action(async (countArg: string | undefined, options) => {
23
+ let uncloudRunner: UncloudRunner | null = null;
24
+
25
+ try {
26
+ const appName = options.app || getAppName();
27
+ const platformToken = getPlatformToken();
28
+ const platformClient = new PlatformClient(platformToken);
29
+ const clusterManager = new ClusterManager(platformClient);
30
+ uncloudRunner = new UncloudRunner(platformClient);
31
+
32
+ // Get current app state
33
+ const app = await platformClient.getApp(appName);
34
+ if (!app) {
35
+ console.log(chalk.red(`\nApp '${appName}' not found\n`));
36
+ console.log(`Run ${chalk.bold('hackerrun deploy')} to create an app first.\n`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const currentNodeCount = app.nodes.length;
41
+
42
+ // If no count provided, show current scale info
43
+ if (!countArg) {
44
+ console.log(chalk.cyan(`\nApp '${appName}' Scale Info:\n`));
45
+ console.log(` Current nodes: ${chalk.bold(currentNodeCount.toString())}`);
46
+ console.log();
47
+
48
+ // List nodes with details
49
+ app.nodes.forEach((node, index) => {
50
+ const primaryLabel = node.isPrimary ? chalk.yellow(' (primary)') : '';
51
+ console.log(` ${index + 1}. ${chalk.bold(node.name)}${primaryLabel}`);
52
+ console.log(` IPv6: ${node.ipv6 || 'pending'}`);
53
+ console.log();
54
+ });
55
+
56
+ console.log(chalk.dim('Usage:'));
57
+ console.log(chalk.dim(` hackerrun scale 3 # Scale to 3 nodes`));
58
+ console.log(chalk.dim(` hackerrun scale +1 # Add 1 node`));
59
+ console.log(chalk.dim(` hackerrun scale -1 # Remove 1 node`));
60
+ console.log();
61
+ return;
62
+ }
63
+
64
+ // Parse count argument
65
+ let targetCount: number;
66
+ if (countArg.startsWith('+')) {
67
+ // Add nodes
68
+ const delta = parseInt(countArg.slice(1), 10);
69
+ if (isNaN(delta) || delta <= 0) {
70
+ console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
71
+ process.exit(1);
72
+ }
73
+ targetCount = currentNodeCount + delta;
74
+ } else if (countArg.startsWith('-')) {
75
+ // Remove nodes
76
+ const delta = parseInt(countArg.slice(1), 10);
77
+ if (isNaN(delta) || delta <= 0) {
78
+ console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
79
+ process.exit(1);
80
+ }
81
+ targetCount = currentNodeCount - delta;
82
+ } else {
83
+ // Absolute count
84
+ targetCount = parseInt(countArg, 10);
85
+ if (isNaN(targetCount)) {
86
+ console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
87
+ process.exit(1);
88
+ }
89
+ }
90
+
91
+ // Validate target count
92
+ if (targetCount < 1) {
93
+ console.log(chalk.red(`\nCannot scale below 1 node. Use 'hackerrun destroy' to remove the app.\n`));
94
+ process.exit(1);
95
+ }
96
+
97
+ if (targetCount === currentNodeCount) {
98
+ console.log(chalk.yellow(`\nApp already has ${currentNodeCount} node(s). Nothing to do.\n`));
99
+ return;
100
+ }
101
+
102
+ const vmSize = options.size || DEFAULT_VM_SIZE;
103
+
104
+ if (targetCount > currentNodeCount) {
105
+ // Scale up - add nodes
106
+ const nodesToAdd = targetCount - currentNodeCount;
107
+ console.log(chalk.cyan(`\nScaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (+${nodesToAdd})\n`));
108
+
109
+ for (let i = 0; i < nodesToAdd; i++) {
110
+ console.log(chalk.cyan(`\nAdding node ${i + 1} of ${nodesToAdd}...`));
111
+ const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
112
+ console.log(chalk.green(` Added: ${newNode.name} (${newNode.ipv6})`));
113
+ }
114
+
115
+ // Refresh app state and sync gateway for load balancing
116
+ const updatedApp = await platformClient.getApp(appName);
117
+
118
+ // Sync gateway to update Caddyfile with all node IPs
119
+ const syncSpinner = ora('Syncing gateway routes...').start();
120
+ try {
121
+ await platformClient.syncGatewayRoutes(app.location);
122
+ syncSpinner.succeed('Gateway routes synced');
123
+ } catch (error) {
124
+ syncSpinner.warn(`Gateway sync failed: ${(error as Error).message}`);
125
+ }
126
+
127
+ console.log(chalk.green(`\n✓ Scaled to ${updatedApp?.nodes.length} nodes\n`));
128
+
129
+ // Show x-machines hint
130
+ console.log(chalk.dim('To deploy to specific nodes, use x-machines in your compose file:'));
131
+ console.log(chalk.dim(' services:'));
132
+ console.log(chalk.dim(' web:'));
133
+ console.log(chalk.dim(' x-machines:'));
134
+ updatedApp?.nodes.forEach(node => {
135
+ console.log(chalk.dim(` - ${node.name}`));
136
+ });
137
+ console.log();
138
+
139
+ } else {
140
+ // Scale down - remove nodes
141
+ const nodesToRemove = currentNodeCount - targetCount;
142
+
143
+ // All nodes are equal in uncloud - select nodes to remove (most recently added first)
144
+ // Sort by node number descending to remove highest-numbered nodes first
145
+ const sortedNodes = [...app.nodes].sort((a, b) => {
146
+ const numA = parseInt(a.name.split('-').pop() || '0', 10);
147
+ const numB = parseInt(b.name.split('-').pop() || '0', 10);
148
+ return numB - numA; // Descending order
149
+ });
150
+
151
+ const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
152
+
153
+ console.log(chalk.yellow(`\nScaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})\n`));
154
+ console.log(chalk.yellow('The following nodes will be removed:'));
155
+ nodesToRemoveList.forEach(node => {
156
+ console.log(` - ${node.name} (${node.ipv6})`);
157
+ });
158
+ console.log();
159
+
160
+ // Confirm unless --force
161
+ if (!options.force) {
162
+ console.log(chalk.yellow('Warning: All containers on these nodes will be stopped and the VMs deleted.'));
163
+ console.log(chalk.yellow('Data on these nodes will be lost.\n'));
164
+
165
+ const confirmed = await confirm({
166
+ message: 'Are you sure you want to remove these nodes?',
167
+ default: false,
168
+ });
169
+
170
+ if (!confirmed) {
171
+ console.log(chalk.dim('\nScale cancelled.\n'));
172
+ return;
173
+ }
174
+ }
175
+
176
+ // Remove nodes
177
+ for (const node of nodesToRemoveList) {
178
+ console.log(chalk.cyan(`\nRemoving node '${node.name}'...`));
179
+
180
+ try {
181
+ // Step 1: Remove from uncloud cluster
182
+ const spinner = ora('Removing from uncloud cluster...').start();
183
+ await uncloudRunner.removeNode(appName, node.name);
184
+ spinner.succeed('Removed from cluster');
185
+
186
+ // Step 2: Delete VM from Ubicloud
187
+ spinner.start('Deleting VM...');
188
+ await platformClient.deleteVM(app.location, node.name);
189
+ spinner.succeed('VM deleted');
190
+
191
+ // Step 3: Update app state in platform
192
+ spinner.start('Updating app state...');
193
+ const updatedNodes = app.nodes.filter(n => n.name !== node.name);
194
+ app.nodes = updatedNodes;
195
+ await platformClient.saveApp(app);
196
+ spinner.succeed('App state updated');
197
+
198
+ console.log(chalk.green(` Removed: ${node.name}`));
199
+ } catch (error) {
200
+ console.log(chalk.red(` Failed to remove ${node.name}: ${(error as Error).message}`));
201
+ // Continue with other nodes
202
+ }
203
+ }
204
+
205
+ // Refresh app state and sync gateway
206
+ const updatedApp = await platformClient.getApp(appName);
207
+
208
+ // Sync gateway to update Caddyfile with remaining node IPs
209
+ const syncSpinner = ora('Syncing gateway routes...').start();
210
+ try {
211
+ await platformClient.syncGatewayRoutes(app.location);
212
+ syncSpinner.succeed('Gateway routes synced');
213
+ } catch (error) {
214
+ syncSpinner.warn(`Gateway sync failed: ${(error as Error).message}`);
215
+ }
216
+
217
+ console.log(chalk.green(`\n✓ Scaled to ${updatedApp?.nodes.length} nodes\n`));
218
+ }
219
+
220
+ } catch (error) {
221
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
222
+ process.exit(1);
223
+ } finally {
224
+ if (uncloudRunner) {
225
+ uncloudRunner.cleanup();
226
+ }
227
+ }
228
+ });
229
+
230
+ return cmd;
231
+ }