hackerrun 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.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "hackerrun",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to create and manage VMs using Ubicloud API",
5
+ "type": "module",
6
+ "bin": {
7
+ "hackerrun": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsup --watch",
11
+ "build": "tsup",
12
+ "start": "node dist/index.js"
13
+ },
14
+ "keywords": [
15
+ "ubicloud",
16
+ "vm",
17
+ "cli",
18
+ "cloud"
19
+ ],
20
+ "author": "",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@inquirer/prompts": "^7.0.0",
24
+ "chalk": "^5.3.0",
25
+ "commander": "^12.0.0",
26
+ "dotenv": "^16.4.7",
27
+ "ora": "^8.1.1",
28
+ "yaml": "^2.8.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.10.2",
32
+ "tsup": "^8.3.5",
33
+ "typescript": "^5.7.2"
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ }
38
+ }
@@ -0,0 +1,394 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { execSync } from 'child_process';
5
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ import { getPlatformToken } from '../lib/platform-auth.js';
9
+ import { PlatformClient } from '../lib/platform-client.js';
10
+ import { linkApp, readAppConfig } from '../lib/app-config.js';
11
+ import { UncloudRunner } from '../lib/uncloud-runner.js';
12
+ import YAML from 'yaml';
13
+
14
+ /**
15
+ * Remove an uncloud context from the config file
16
+ */
17
+ function removeUncloudContext(contextName: string): boolean {
18
+ const configPath = join(homedir(), '.config', 'uncloud', 'config.yaml');
19
+
20
+ if (!existsSync(configPath)) {
21
+ return false;
22
+ }
23
+
24
+ try {
25
+ const content = readFileSync(configPath, 'utf-8');
26
+ const config = YAML.parse(content);
27
+
28
+ if (!config.contexts || !config.contexts[contextName]) {
29
+ return false;
30
+ }
31
+
32
+ // Remove the context
33
+ delete config.contexts[contextName];
34
+
35
+ // If current context was the deleted one, switch to another context
36
+ if (config.current_context === contextName) {
37
+ const remainingContexts = Object.keys(config.contexts);
38
+ config.current_context = remainingContexts.length > 0 ? remainingContexts[0] : '';
39
+ }
40
+
41
+ writeFileSync(configPath, YAML.stringify(config));
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function createAppCommands() {
49
+ // Apps list command
50
+ const appsCmd = new Command('apps');
51
+ appsCmd
52
+ .description('List all your apps and their infrastructure')
53
+ .action(async () => {
54
+ try {
55
+ // Fetch apps from backend (single source of truth)
56
+ const platformToken = getPlatformToken();
57
+ const platformClient = new PlatformClient(platformToken);
58
+ const apps = await platformClient.listApps();
59
+
60
+ if (apps.length === 0) {
61
+ console.log(chalk.yellow('\nNo apps deployed yet.\n'));
62
+ console.log(`Run ${chalk.bold('hackerrun deploy')} to deploy your first app!\n`);
63
+ return;
64
+ }
65
+
66
+ console.log(chalk.cyan('\n Your Apps:\n'));
67
+
68
+ apps.forEach(app => {
69
+ console.log(chalk.bold(` ${app.appName}`));
70
+ console.log(` Domain: ${app.domainName}.hackerrun.app`);
71
+ console.log(` URL: https://${app.domainName}.hackerrun.app`);
72
+ console.log(` Location: ${app.location}`);
73
+ console.log(` Nodes: ${app.nodes.length}`);
74
+ console.log(` Created: ${new Date(app.createdAt).toLocaleString()}`);
75
+ if (app.lastDeployedAt) {
76
+ console.log(` Last Deploy: ${new Date(app.lastDeployedAt).toLocaleString()}`);
77
+ }
78
+ const primaryNode = app.nodes.find(n => n.isPrimary);
79
+ console.log(` Primary Node: ${primaryNode?.ipv6 || primaryNode?.ipv4 || 'N/A'}`);
80
+ console.log();
81
+ });
82
+
83
+ console.log(chalk.green(`Total: ${apps.length} app(s)\n`));
84
+ } catch (error) {
85
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
86
+ process.exit(1);
87
+ }
88
+ });
89
+
90
+ // Nodes list command
91
+ const nodesCmd = new Command('nodes');
92
+ nodesCmd
93
+ .description('List all VMs in an app\'s cluster')
94
+ .argument('<app>', 'App name')
95
+ .action(async (appName: string) => {
96
+ try {
97
+ const platformToken = getPlatformToken();
98
+ const platformClient = new PlatformClient(platformToken);
99
+ const app = await platformClient.getApp(appName);
100
+
101
+ if (!app) {
102
+ console.log(chalk.red(`\nApp '${appName}' not found\n`));
103
+ process.exit(1);
104
+ }
105
+
106
+ console.log(chalk.cyan(`\nNodes in '${appName}':\n`));
107
+
108
+ app.nodes.forEach((node, index) => {
109
+ const primaryLabel = node.isPrimary ? chalk.yellow(' (primary)') : '';
110
+ console.log(` ${index + 1}. ${chalk.bold(node.name)}${primaryLabel}`);
111
+ console.log(` IPv6: ${node.ipv6 || 'pending'}`);
112
+ if (node.ipv4) {
113
+ console.log(` IPv4: ${node.ipv4}`);
114
+ }
115
+ console.log(` ID: ${node.id}`);
116
+ console.log();
117
+ });
118
+
119
+ console.log(chalk.green(`Total: ${app.nodes.length} node(s)\n`));
120
+ } catch (error) {
121
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
122
+ process.exit(1);
123
+ }
124
+ });
125
+
126
+ // SSH command
127
+ const sshCmd = new Command('ssh');
128
+ sshCmd
129
+ .description('SSH into an app\'s VM')
130
+ .argument('<app>', 'App name')
131
+ .option('-n, --node <node>', 'Node name or index (defaults to primary node)')
132
+ .action(async (appName: string, options) => {
133
+ const platformToken = getPlatformToken();
134
+ const platformClient = new PlatformClient(platformToken);
135
+ const uncloudRunner = new UncloudRunner(platformClient);
136
+
137
+ try {
138
+ const app = await platformClient.getApp(appName);
139
+
140
+ if (!app) {
141
+ console.log(chalk.red(`\nApp '${appName}' not found\n`));
142
+ process.exit(1);
143
+ }
144
+
145
+ let targetNode;
146
+
147
+ if (options.node) {
148
+ // Check if it's a number (index) or name
149
+ const nodeIndex = parseInt(options.node);
150
+ if (!isNaN(nodeIndex) && nodeIndex > 0 && nodeIndex <= app.nodes.length) {
151
+ targetNode = app.nodes[nodeIndex - 1];
152
+ } else {
153
+ targetNode = app.nodes.find(n => n.name === options.node);
154
+ }
155
+
156
+ if (!targetNode) {
157
+ console.log(chalk.red(`\nNode '${options.node}' not found\n`));
158
+ process.exit(1);
159
+ }
160
+ } else {
161
+ // Default to primary node
162
+ targetNode = app.nodes.find(n => n.isPrimary);
163
+ }
164
+
165
+ // Get IP address (prefer IPv6, fall back to IPv4)
166
+ const nodeIp = targetNode?.ipv6 || targetNode?.ipv4;
167
+ if (!targetNode || !nodeIp) {
168
+ console.log(chalk.red(`\nTarget node not found or has no IP\n`));
169
+ process.exit(1);
170
+ }
171
+
172
+ // Get connection info (handles certificate and gateway tunnel if needed)
173
+ const connInfo = await uncloudRunner.getConnectionInfo(appName);
174
+
175
+ if (connInfo.viaGateway) {
176
+ console.log(chalk.cyan(`\nConnecting to ${targetNode.name} via gateway...\n`));
177
+ } else {
178
+ console.log(chalk.cyan(`\nConnecting to ${targetNode.name}...\n`));
179
+ }
180
+
181
+ // SSH to the VM (certificate is in SSH agent)
182
+ if (connInfo.viaGateway && connInfo.localPort) {
183
+ // Use the tunnel
184
+ execSync(
185
+ `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`,
186
+ { stdio: 'inherit' }
187
+ );
188
+ } else {
189
+ // Direct connection
190
+ execSync(
191
+ `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${nodeIp}`,
192
+ { stdio: 'inherit' }
193
+ );
194
+ }
195
+ } catch (error) {
196
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
197
+ process.exit(1);
198
+ } finally {
199
+ uncloudRunner.cleanup();
200
+ }
201
+ });
202
+
203
+ // Destroy command
204
+ const destroyCmd = new Command('destroy');
205
+ destroyCmd
206
+ .description('Delete an app and all its infrastructure')
207
+ .argument('<app>', 'App name')
208
+ .option('--force', 'Skip confirmation')
209
+ .action(async (appName: string, options) => {
210
+ try {
211
+ const platformToken = getPlatformToken();
212
+ const platformClient = new PlatformClient(platformToken);
213
+ const app = await platformClient.getApp(appName);
214
+
215
+ if (!app) {
216
+ console.log(chalk.red(`\n❌ App '${appName}' not found\n`));
217
+ process.exit(1);
218
+ }
219
+
220
+ // Confirm deletion
221
+ if (!options.force) {
222
+ console.log(chalk.yellow(`\n⚠️ You are about to delete '${appName}' and all its infrastructure:`));
223
+ console.log(` - ${app.nodes.length} VM(s) will be deleted`);
224
+ console.log(` - All data will be lost\n`);
225
+
226
+ // For now, we'll skip interactive confirmation in CLI
227
+ // In a real implementation, you'd use a package like 'prompts'
228
+ console.log(chalk.red('Use --force flag to confirm deletion\n'));
229
+ process.exit(1);
230
+ }
231
+
232
+ const spinner = ora('Deleting infrastructure...').start();
233
+
234
+ try {
235
+ // Delete all VMs via platform API
236
+ for (const node of app.nodes) {
237
+ spinner.text = `Deleting VM '${node.name}'...`;
238
+ await platformClient.deleteVM(app.location, node.name);
239
+ }
240
+
241
+ // Remove app from backend
242
+ spinner.text = 'Removing app from database...';
243
+ await platformClient.deleteApp(appName);
244
+
245
+ // Remove uncloud context from local config
246
+ spinner.text = 'Cleaning up local configuration...';
247
+ const contextName = app.uncloudContext || appName;
248
+ removeUncloudContext(contextName);
249
+
250
+ spinner.succeed(chalk.green(`App '${appName}' destroyed successfully`));
251
+
252
+ console.log(chalk.cyan('\n💡 All infrastructure has been deleted.\n'));
253
+ } catch (error) {
254
+ spinner.fail(chalk.red('Failed to destroy app'));
255
+ throw error;
256
+ }
257
+ } catch (error) {
258
+ console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
259
+ process.exit(1);
260
+ }
261
+ });
262
+
263
+ // Link command
264
+ const linkCmd = new Command('link');
265
+ linkCmd
266
+ .description('Link current directory to an existing app')
267
+ .argument('<app-name>', 'App name to link to')
268
+ .action(async (appName: string) => {
269
+ try {
270
+ const platformToken = getPlatformToken();
271
+ const platformClient = new PlatformClient(platformToken);
272
+
273
+ // Check if app exists
274
+ const app = await platformClient.getApp(appName);
275
+ if (!app) {
276
+ console.log(chalk.red(`\nApp '${appName}' not found\n`));
277
+ console.log(`Run ${chalk.bold('hackerrun apps')} to see your apps.\n`);
278
+ process.exit(1);
279
+ }
280
+
281
+ // Check if already linked
282
+ const existingConfig = readAppConfig();
283
+ if (existingConfig) {
284
+ if (existingConfig.appName === appName) {
285
+ console.log(chalk.yellow(`\nThis directory is already linked to '${appName}'\n`));
286
+ return;
287
+ }
288
+ console.log(chalk.yellow(`\nThis directory is currently linked to '${existingConfig.appName}'`));
289
+ console.log(`Updating link to '${appName}'...\n`);
290
+ }
291
+
292
+ // Create hackerrun.yaml
293
+ linkApp(appName);
294
+
295
+ console.log(chalk.green(`\nLinked to app '${appName}'\n`));
296
+ console.log(` Domain: https://${app.domainName}.hackerrun.app`);
297
+ console.log(` Run ${chalk.bold('hackerrun deploy')} to deploy changes.\n`);
298
+ } catch (error) {
299
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
300
+ process.exit(1);
301
+ }
302
+ });
303
+
304
+ // Rename command
305
+ const renameCmd = new Command('rename');
306
+ renameCmd
307
+ .description('Rename an app (per-user unique)')
308
+ .requiredOption('--app <current-name>', 'Current app name')
309
+ .argument('<new-name>', 'New app name')
310
+ .action(async (newName: string, options) => {
311
+ try {
312
+ const platformToken = getPlatformToken();
313
+ const platformClient = new PlatformClient(platformToken);
314
+ const currentName = options.app;
315
+
316
+ // Check if app exists
317
+ const app = await platformClient.getApp(currentName);
318
+ if (!app) {
319
+ console.log(chalk.red(`\nApp '${currentName}' not found\n`));
320
+ process.exit(1);
321
+ }
322
+
323
+ const spinner = ora(`Renaming app '${currentName}' to '${newName}'...`).start();
324
+
325
+ try {
326
+ const updatedApp = await platformClient.renameApp(currentName, newName);
327
+ spinner.succeed(chalk.green(`App renamed successfully`));
328
+
329
+ console.log(`\n Old name: ${currentName}`);
330
+ console.log(` New name: ${updatedApp.appName}`);
331
+ console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app\n`);
332
+
333
+ // Update local config if linked to this app
334
+ const existingConfig = readAppConfig();
335
+ if (existingConfig?.appName === currentName) {
336
+ linkApp(newName);
337
+ console.log(chalk.dim(`Updated local hackerrun.yaml\n`));
338
+ }
339
+ } catch (error) {
340
+ spinner.fail(chalk.red('Failed to rename app'));
341
+ throw error;
342
+ }
343
+ } catch (error) {
344
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
345
+ process.exit(1);
346
+ }
347
+ });
348
+
349
+ // Domain command
350
+ const domainCmd = new Command('domain');
351
+ domainCmd
352
+ .description('Change app\'s domain name (globally unique)')
353
+ .requiredOption('--app <app-name>', 'App name')
354
+ .argument('<new-domain>', 'New domain name (without .hackerrun.app)')
355
+ .action(async (newDomain: string, options) => {
356
+ try {
357
+ const platformToken = getPlatformToken();
358
+ const platformClient = new PlatformClient(platformToken);
359
+ const appName = options.app;
360
+
361
+ // Check if app exists
362
+ const app = await platformClient.getApp(appName);
363
+ if (!app) {
364
+ console.log(chalk.red(`\nApp '${appName}' not found\n`));
365
+ process.exit(1);
366
+ }
367
+
368
+ const oldDomain = app.domainName;
369
+ const spinner = ora(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
370
+
371
+ try {
372
+ const updatedApp = await platformClient.renameDomain(appName, newDomain);
373
+ spinner.succeed(chalk.green(`Domain changed successfully`));
374
+
375
+ // Sync gateway routes with new domain
376
+ spinner.start('Syncing gateway routes...');
377
+ await platformClient.syncGatewayRoutes(app.location);
378
+ spinner.succeed(chalk.green('Gateway routes synced'));
379
+
380
+ 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`);
383
+ } catch (error) {
384
+ spinner.fail(chalk.red('Failed to change domain'));
385
+ throw error;
386
+ }
387
+ } catch (error) {
388
+ console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
389
+ process.exit(1);
390
+ }
391
+ });
392
+
393
+ return { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd };
394
+ }