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/.claude/settings.local.json +22 -0
- package/.env.example +9 -0
- package/CLAUDE.md +532 -0
- package/README.md +94 -0
- package/dist/index.js +2813 -0
- package/package.json +38 -0
- package/src/commands/app.ts +394 -0
- package/src/commands/builds.ts +314 -0
- package/src/commands/config.ts +129 -0
- package/src/commands/connect.ts +197 -0
- package/src/commands/deploy.ts +227 -0
- package/src/commands/env.ts +174 -0
- package/src/commands/login.ts +120 -0
- package/src/commands/logs.ts +97 -0
- package/src/index.ts +43 -0
- package/src/lib/app-config.ts +95 -0
- package/src/lib/cluster.ts +428 -0
- package/src/lib/config.ts +137 -0
- package/src/lib/platform-auth.ts +20 -0
- package/src/lib/platform-client.ts +637 -0
- package/src/lib/platform.ts +87 -0
- package/src/lib/ssh-cert.ts +264 -0
- package/src/lib/uncloud-runner.ts +342 -0
- package/src/lib/uncloud.ts +149 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { ClusterManager } from '../lib/cluster.js';
|
|
6
|
+
import { PlatformDetector } from '../lib/platform.js';
|
|
7
|
+
import { UncloudManager } from '../lib/uncloud.js';
|
|
8
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
9
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
10
|
+
import { getAppName, hasAppConfig, linkApp } from '../lib/app-config.js';
|
|
11
|
+
import { UncloudRunner } from '../lib/uncloud-runner.js';
|
|
12
|
+
|
|
13
|
+
// Default VM settings
|
|
14
|
+
const DEFAULT_LOCATION = 'eu-central-h1';
|
|
15
|
+
const DEFAULT_SIZE = 'burstable-1';
|
|
16
|
+
const DEFAULT_STORAGE_SIZE = 10; // GB
|
|
17
|
+
const DEFAULT_IMAGE = 'ubuntu-noble';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inject domain into compose file using x-ports extension
|
|
21
|
+
* This allows custom domain routing without modifying uncloud-dns
|
|
22
|
+
*/
|
|
23
|
+
function injectDomainIntoCompose(composePath: string, domain: string): void {
|
|
24
|
+
if (!existsSync(composePath)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const content = readFileSync(composePath, 'utf-8');
|
|
29
|
+
|
|
30
|
+
// Check if x-ports already exists with our domain
|
|
31
|
+
if (content.includes(`x-ports:`)) {
|
|
32
|
+
// Domain injection already present, skip
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Find services that have ports defined and inject x-ports
|
|
37
|
+
// This is a simple approach - we add x-ports to services with ports
|
|
38
|
+
const lines = content.split('\n');
|
|
39
|
+
const newLines: string[] = [];
|
|
40
|
+
let inService = false;
|
|
41
|
+
let currentIndent = 0;
|
|
42
|
+
let hasInjectedXPorts = false;
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
newLines.push(line);
|
|
47
|
+
|
|
48
|
+
// Check if we're entering a service that has ports
|
|
49
|
+
if (line.match(/^\s+ports:/) && !hasInjectedXPorts) {
|
|
50
|
+
// Add x-ports after the ports section ends
|
|
51
|
+
// Find the indent level
|
|
52
|
+
const indent = line.match(/^(\s*)/)?.[1] || ' ';
|
|
53
|
+
|
|
54
|
+
// Look ahead for the port value(s)
|
|
55
|
+
let j = i + 1;
|
|
56
|
+
while (j < lines.length && lines[j].match(/^\s+-/)) {
|
|
57
|
+
newLines.push(lines[j]);
|
|
58
|
+
j++;
|
|
59
|
+
}
|
|
60
|
+
i = j - 1;
|
|
61
|
+
|
|
62
|
+
// Inject x-ports with our domain
|
|
63
|
+
newLines.push(`${indent}x-ports:`);
|
|
64
|
+
newLines.push(`${indent} - "443:443"`);
|
|
65
|
+
newLines.push(`${indent} host: "${domain}"`);
|
|
66
|
+
hasInjectedXPorts = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only write if we made changes
|
|
71
|
+
if (hasInjectedXPorts) {
|
|
72
|
+
writeFileSync(composePath, newLines.join('\n'), 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createDeployCommand() {
|
|
77
|
+
const cmd = new Command('deploy');
|
|
78
|
+
cmd
|
|
79
|
+
.description('Deploy your application to hackerrun')
|
|
80
|
+
.option('-n, --name <name>', 'App name (defaults to hackerrun.yaml or directory name)')
|
|
81
|
+
.option('--app <app>', 'App name (alias for --name, for CI/CD compatibility)')
|
|
82
|
+
.option('-l, --location <location>', 'VM location')
|
|
83
|
+
.option('-s, --size <size>', 'VM size (default: burstable-1)')
|
|
84
|
+
.option('--storage <gb>', 'Storage size in GB (default: 10)')
|
|
85
|
+
.option('-i, --image <image>', 'Boot image')
|
|
86
|
+
.option('--build-token <token>', 'Build token for CI/CD (bypasses normal auth)')
|
|
87
|
+
.action(async (options) => {
|
|
88
|
+
try {
|
|
89
|
+
// Check platform compatibility
|
|
90
|
+
PlatformDetector.ensureSupported();
|
|
91
|
+
|
|
92
|
+
// Check uncloud CLI is installed (offers to auto-install if missing)
|
|
93
|
+
await UncloudManager.ensureInstalled();
|
|
94
|
+
|
|
95
|
+
// Determine authentication mode
|
|
96
|
+
// CI/CD mode: use build token if provided
|
|
97
|
+
// Interactive mode: use user's platform token
|
|
98
|
+
let platformToken: string;
|
|
99
|
+
const isCIBuild = !!options.buildToken;
|
|
100
|
+
|
|
101
|
+
if (isCIBuild) {
|
|
102
|
+
platformToken = options.buildToken;
|
|
103
|
+
} else {
|
|
104
|
+
platformToken = getPlatformToken();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const platformClient = new PlatformClient(platformToken);
|
|
108
|
+
const clusterManager = new ClusterManager(platformClient);
|
|
109
|
+
const uncloudRunner = new UncloudRunner(platformClient);
|
|
110
|
+
|
|
111
|
+
// Determine app name: --app or --name flag > hackerrun.yaml > directory name
|
|
112
|
+
const appName = options.app || options.name || getAppName();
|
|
113
|
+
|
|
114
|
+
// Resolve VM settings
|
|
115
|
+
const location = options.location || DEFAULT_LOCATION;
|
|
116
|
+
const vmSize = options.size || DEFAULT_SIZE;
|
|
117
|
+
const storageSize = options.storage ? parseInt(options.storage) : DEFAULT_STORAGE_SIZE;
|
|
118
|
+
const bootImage = options.image || DEFAULT_IMAGE;
|
|
119
|
+
|
|
120
|
+
console.log(chalk.cyan(`\nDeploying '${appName}' to hackerrun...\n`));
|
|
121
|
+
|
|
122
|
+
// Check if app already has infrastructure (from backend)
|
|
123
|
+
let cluster = await platformClient.getApp(appName);
|
|
124
|
+
let isFirstDeploy = false;
|
|
125
|
+
|
|
126
|
+
if (!cluster) {
|
|
127
|
+
// First deployment - create infrastructure
|
|
128
|
+
isFirstDeploy = true;
|
|
129
|
+
console.log(chalk.yellow('First deployment - creating infrastructure...\n'));
|
|
130
|
+
|
|
131
|
+
// Initialize cluster (saves to platform and returns with domainName)
|
|
132
|
+
cluster = await clusterManager.initializeCluster({
|
|
133
|
+
appName,
|
|
134
|
+
location,
|
|
135
|
+
vmSize,
|
|
136
|
+
storageSize,
|
|
137
|
+
bootImage,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
console.log(chalk.cyan('\nWhat just happened:'));
|
|
141
|
+
console.log(` ${chalk.green('✓')} Created 1 IPv6-only VM (${vmSize})`);
|
|
142
|
+
console.log(` ${chalk.green('✓')} Installed Docker and Uncloud daemon`);
|
|
143
|
+
console.log(` ${chalk.green('✓')} Initialized uncloud cluster`);
|
|
144
|
+
console.log(` ${chalk.green('✓')} Assigned domain: ${cluster.domainName}.hackerrun.app`);
|
|
145
|
+
console.log();
|
|
146
|
+
|
|
147
|
+
// Create hackerrun.yaml if not already present
|
|
148
|
+
if (!hasAppConfig()) {
|
|
149
|
+
linkApp(appName);
|
|
150
|
+
console.log(chalk.dim(`Created hackerrun.yaml for app linking\n`));
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
console.log(chalk.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))\n`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Get primary node from backend
|
|
157
|
+
const primaryNode = await platformClient.getPrimaryNode(appName);
|
|
158
|
+
if (!primaryNode || !primaryNode.ipv6) {
|
|
159
|
+
throw new Error('Primary node not found or has no IPv6 address');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Deploy using uncloud with SSH certificate authentication
|
|
163
|
+
console.log(chalk.cyan('\nRunning deployment...\n'));
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Run uc deploy using ssh+cli:// connector
|
|
167
|
+
await uncloudRunner.deploy(appName, process.cwd());
|
|
168
|
+
|
|
169
|
+
console.log(chalk.green('\nApp deployed successfully!'));
|
|
170
|
+
|
|
171
|
+
// Register route for this app (maps domain to VM IPv6, port 80 for HTTP)
|
|
172
|
+
if (cluster.domainName) {
|
|
173
|
+
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}`));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Update last deployed timestamp on backend
|
|
188
|
+
await platformClient.updateLastDeployed(appName);
|
|
189
|
+
|
|
190
|
+
// Show summary
|
|
191
|
+
console.log(chalk.cyan('\nDeployment Summary:\n'));
|
|
192
|
+
console.log(` App Name: ${chalk.bold(appName)}`);
|
|
193
|
+
console.log(` Domain: ${chalk.bold(`${cluster.domainName}.hackerrun.app`)}`);
|
|
194
|
+
console.log(` URL: ${chalk.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
|
|
195
|
+
console.log(` Location: ${cluster.location}`);
|
|
196
|
+
console.log(` Nodes: ${cluster.nodes.length}`);
|
|
197
|
+
|
|
198
|
+
console.log(chalk.cyan('\nInfrastructure:\n'));
|
|
199
|
+
cluster.nodes.forEach((node, index) => {
|
|
200
|
+
const prefix = node.isPrimary ? chalk.yellow('(primary)') : ' ';
|
|
201
|
+
const ip = node.ipv6 || node.ipv4 || 'pending';
|
|
202
|
+
console.log(` ${prefix} ${node.name} - ${ip}`);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
console.log(chalk.cyan('\nNext steps:\n'));
|
|
206
|
+
console.log(` View logs: ${chalk.bold(`hackerrun logs ${appName}`)}`);
|
|
207
|
+
console.log(` SSH access: ${chalk.bold(`hackerrun ssh ${appName}`)}`);
|
|
208
|
+
console.log(` Change domain: ${chalk.bold(`hackerrun domain --app ${appName} <new-name>`)}`);
|
|
209
|
+
console.log();
|
|
210
|
+
|
|
211
|
+
// Cleanup SSH sessions and tunnels
|
|
212
|
+
uncloudRunner.cleanup();
|
|
213
|
+
|
|
214
|
+
} catch (error) {
|
|
215
|
+
uncloudRunner.cleanup();
|
|
216
|
+
console.log(chalk.red('\nDeployment failed'));
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return cmd;
|
|
227
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { password } from '@inquirer/prompts';
|
|
5
|
+
import { readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
7
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
8
|
+
import { getAppName } from '../lib/app-config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a .env file into key-value pairs
|
|
12
|
+
*/
|
|
13
|
+
function parseEnvFile(content: string): Record<string, string> {
|
|
14
|
+
const vars: Record<string, string> = {};
|
|
15
|
+
for (const line of content.split('\n')) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
18
|
+
|
|
19
|
+
const eqIndex = trimmed.indexOf('=');
|
|
20
|
+
if (eqIndex === -1) continue;
|
|
21
|
+
|
|
22
|
+
const key = trimmed.slice(0, eqIndex);
|
|
23
|
+
let value = trimmed.slice(eqIndex + 1);
|
|
24
|
+
|
|
25
|
+
// Handle quoted values
|
|
26
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
27
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
28
|
+
value = value.slice(1, -1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
vars[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return vars;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createEnvCommand() {
|
|
37
|
+
const cmd = new Command('env');
|
|
38
|
+
cmd.description('Manage environment variables for an app');
|
|
39
|
+
|
|
40
|
+
// env set KEY=value or env set KEY (prompts for value)
|
|
41
|
+
cmd
|
|
42
|
+
.command('set')
|
|
43
|
+
.description('Set an environment variable')
|
|
44
|
+
.argument('<keyValue>', 'KEY=value or just KEY (will prompt for value)')
|
|
45
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
|
|
46
|
+
.action(async (keyValue: string, options) => {
|
|
47
|
+
try {
|
|
48
|
+
const appName = options.app || getAppName();
|
|
49
|
+
const platformToken = getPlatformToken();
|
|
50
|
+
const platformClient = new PlatformClient(platformToken);
|
|
51
|
+
|
|
52
|
+
let key: string;
|
|
53
|
+
let value: string;
|
|
54
|
+
|
|
55
|
+
if (keyValue.includes('=')) {
|
|
56
|
+
const parts = keyValue.split('=');
|
|
57
|
+
key = parts[0];
|
|
58
|
+
value = parts.slice(1).join('='); // Handle values with = in them
|
|
59
|
+
} else {
|
|
60
|
+
key = keyValue;
|
|
61
|
+
// Prompt for hidden input
|
|
62
|
+
value = await password({
|
|
63
|
+
message: `Enter value for ${key}:`,
|
|
64
|
+
mask: '*',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!key) {
|
|
69
|
+
console.error(chalk.red('\nError: Key cannot be empty\n'));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const spinner = ora(`Setting ${key}...`).start();
|
|
74
|
+
await platformClient.setEnvVar(appName, key, value);
|
|
75
|
+
spinner.succeed(`Set ${key}`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// env upload .env
|
|
83
|
+
cmd
|
|
84
|
+
.command('upload')
|
|
85
|
+
.description('Upload environment variables from a file')
|
|
86
|
+
.argument('<file>', 'Path to .env file')
|
|
87
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
|
|
88
|
+
.action(async (filePath: string, options) => {
|
|
89
|
+
try {
|
|
90
|
+
const appName = options.app || getAppName();
|
|
91
|
+
const platformToken = getPlatformToken();
|
|
92
|
+
const platformClient = new PlatformClient(platformToken);
|
|
93
|
+
|
|
94
|
+
if (!existsSync(filePath)) {
|
|
95
|
+
console.error(chalk.red(`\nError: File not found: ${filePath}\n`));
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
100
|
+
const vars = parseEnvFile(content);
|
|
101
|
+
const count = Object.keys(vars).length;
|
|
102
|
+
|
|
103
|
+
if (count === 0) {
|
|
104
|
+
console.log(chalk.yellow('\nNo environment variables found in file.\n'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const spinner = ora(`Uploading ${count} variable${count > 1 ? 's' : ''}...`).start();
|
|
109
|
+
await platformClient.setEnvVars(appName, vars);
|
|
110
|
+
spinner.succeed(`Uploaded ${count} environment variable${count > 1 ? 's' : ''}`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// env list
|
|
118
|
+
cmd
|
|
119
|
+
.command('list')
|
|
120
|
+
.description('List environment variables')
|
|
121
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
|
|
122
|
+
.action(async (options) => {
|
|
123
|
+
try {
|
|
124
|
+
const appName = options.app || getAppName();
|
|
125
|
+
const platformToken = getPlatformToken();
|
|
126
|
+
const platformClient = new PlatformClient(platformToken);
|
|
127
|
+
|
|
128
|
+
const spinner = ora('Fetching environment variables...').start();
|
|
129
|
+
const vars = await platformClient.listEnvVars(appName);
|
|
130
|
+
spinner.stop();
|
|
131
|
+
|
|
132
|
+
if (vars.length === 0) {
|
|
133
|
+
console.log(chalk.yellow(`\nNo environment variables set for '${appName}'.\n`));
|
|
134
|
+
console.log(chalk.cyan('Set variables with:\n'));
|
|
135
|
+
console.log(` hackerrun env set KEY=value`);
|
|
136
|
+
console.log(` hackerrun env upload .env\n`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(chalk.cyan(`\nEnvironment variables for '${appName}':\n`));
|
|
141
|
+
for (const v of vars) {
|
|
142
|
+
const masked = '*'.repeat(Math.min(v.valueLength, 20));
|
|
143
|
+
console.log(` ${chalk.bold(v.key)}=${chalk.dim(masked)}`);
|
|
144
|
+
}
|
|
145
|
+
console.log();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// env unset KEY
|
|
153
|
+
cmd
|
|
154
|
+
.command('unset')
|
|
155
|
+
.description('Remove an environment variable')
|
|
156
|
+
.argument('<key>', 'Environment variable key')
|
|
157
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
|
|
158
|
+
.action(async (key: string, options) => {
|
|
159
|
+
try {
|
|
160
|
+
const appName = options.app || getAppName();
|
|
161
|
+
const platformToken = getPlatformToken();
|
|
162
|
+
const platformClient = new PlatformClient(platformToken);
|
|
163
|
+
|
|
164
|
+
const spinner = ora(`Removing ${key}...`).start();
|
|
165
|
+
await platformClient.unsetEnvVar(appName, key);
|
|
166
|
+
spinner.succeed(`Removed ${key}`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return cmd;
|
|
174
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { ConfigManager } from '../lib/config.js';
|
|
5
|
+
|
|
6
|
+
const PLATFORM_API_URL = process.env.HACKERRUN_API_URL || 'http://localhost:3000';
|
|
7
|
+
|
|
8
|
+
interface DeviceFlowInitiation {
|
|
9
|
+
deviceCode: string;
|
|
10
|
+
userCode: string;
|
|
11
|
+
verificationUri: string;
|
|
12
|
+
expiresIn: number;
|
|
13
|
+
interval: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DeviceFlowResult {
|
|
17
|
+
status: 'pending' | 'authorized' | 'expired';
|
|
18
|
+
token?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createLoginCommand() {
|
|
22
|
+
const cmd = new Command('login');
|
|
23
|
+
cmd.description('Login to HackerRun platform');
|
|
24
|
+
|
|
25
|
+
cmd.action(async () => {
|
|
26
|
+
try {
|
|
27
|
+
console.log(chalk.cyan('\n🔐 Logging in to HackerRun\n'));
|
|
28
|
+
|
|
29
|
+
// Step 1: Initiate device flow
|
|
30
|
+
const spinner = ora('Initiating login...').start();
|
|
31
|
+
|
|
32
|
+
const initResponse = await fetch(`${PLATFORM_API_URL}/api/auth/device`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!initResponse.ok) {
|
|
37
|
+
spinner.fail('Failed to initiate login');
|
|
38
|
+
const error = await initResponse.json().catch(() => ({ error: 'Unknown error' }));
|
|
39
|
+
console.error(chalk.red(`\nError: ${error.error || initResponse.statusText}\n`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const deviceFlow: DeviceFlowInitiation = await initResponse.json();
|
|
44
|
+
spinner.succeed('Login initiated');
|
|
45
|
+
|
|
46
|
+
// Step 2: Show user code and URL
|
|
47
|
+
console.log(chalk.cyan('\n📋 Please complete the following steps:\n'));
|
|
48
|
+
console.log(` 1. Visit: ${chalk.bold.blue(deviceFlow.verificationUri)}`);
|
|
49
|
+
console.log(` 2. Enter code: ${chalk.bold.yellow(deviceFlow.userCode)}\n`);
|
|
50
|
+
console.log(chalk.dim(`Code expires in ${Math.floor(deviceFlow.expiresIn / 60)} minutes\n`));
|
|
51
|
+
|
|
52
|
+
// Step 3: Poll for authorization
|
|
53
|
+
const pollSpinner = ora('Waiting for authorization...').start();
|
|
54
|
+
|
|
55
|
+
const pollInterval = deviceFlow.interval * 1000; // Convert to milliseconds
|
|
56
|
+
const maxAttempts = Math.floor(deviceFlow.expiresIn / deviceFlow.interval);
|
|
57
|
+
let attempts = 0;
|
|
58
|
+
|
|
59
|
+
while (attempts < maxAttempts) {
|
|
60
|
+
await sleep(pollInterval);
|
|
61
|
+
attempts++;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const pollResponse = await fetch(
|
|
65
|
+
`${PLATFORM_API_URL}/api/auth/device/poll?device_code=${deviceFlow.deviceCode}`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
if (!pollResponse.ok) {
|
|
69
|
+
pollSpinner.fail('Failed to check authorization status');
|
|
70
|
+
console.error(chalk.red('\nPlease try again later\n'));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result: DeviceFlowResult = await pollResponse.json();
|
|
75
|
+
|
|
76
|
+
if (result.status === 'authorized') {
|
|
77
|
+
pollSpinner.succeed(chalk.green('Authorization successful!'));
|
|
78
|
+
|
|
79
|
+
// Save platform token
|
|
80
|
+
const configManager = new ConfigManager();
|
|
81
|
+
configManager.set('apiToken', result.token!);
|
|
82
|
+
|
|
83
|
+
console.log(chalk.green('\n✓ Login successful!\n'));
|
|
84
|
+
console.log(chalk.cyan('You can now use HackerRun CLI:\n'));
|
|
85
|
+
console.log(` ${chalk.bold('hackerrun deploy')} Deploy your application`);
|
|
86
|
+
console.log(` ${chalk.bold('hackerrun app list')} List your apps`);
|
|
87
|
+
console.log(` ${chalk.bold('hackerrun config list')} View configuration\n`);
|
|
88
|
+
|
|
89
|
+
return;
|
|
90
|
+
} else if (result.status === 'expired') {
|
|
91
|
+
pollSpinner.fail('Authorization code expired');
|
|
92
|
+
console.error(chalk.red('\nPlease run `hackerrun login` again\n'));
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Status is 'pending', continue polling
|
|
97
|
+
} catch (error) {
|
|
98
|
+
pollSpinner.fail('Failed to check authorization');
|
|
99
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Timeout
|
|
105
|
+
pollSpinner.fail('Authorization timed out');
|
|
106
|
+
console.error(chalk.red('\nPlease run `hackerrun login` again\n'));
|
|
107
|
+
process.exit(1);
|
|
108
|
+
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return cmd;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sleep(ms: number): Promise<void> {
|
|
119
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
4
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
5
|
+
import { UncloudRunner } from '../lib/uncloud-runner.js';
|
|
6
|
+
|
|
7
|
+
export function createLogsCommand() {
|
|
8
|
+
const cmd = new Command('logs');
|
|
9
|
+
|
|
10
|
+
cmd
|
|
11
|
+
.description('View logs for app services')
|
|
12
|
+
.argument('<app>', 'App name')
|
|
13
|
+
.argument('[service]', 'Service name (optional - lists services if omitted)')
|
|
14
|
+
.option('-f, --follow', 'Follow log output')
|
|
15
|
+
.option('--tail <lines>', 'Number of lines to show from the end of the logs')
|
|
16
|
+
.action(async (appName: string, serviceName: string | undefined, options) => {
|
|
17
|
+
const platformToken = getPlatformToken();
|
|
18
|
+
const platformClient = new PlatformClient(platformToken);
|
|
19
|
+
const uncloudRunner = new UncloudRunner(platformClient);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const app = await platformClient.getApp(appName);
|
|
23
|
+
|
|
24
|
+
if (!app) {
|
|
25
|
+
console.log(chalk.red(`\n App '${appName}' not found\n`));
|
|
26
|
+
console.log(chalk.cyan('Available apps:'));
|
|
27
|
+
const apps = await platformClient.listApps();
|
|
28
|
+
if (apps.length === 0) {
|
|
29
|
+
console.log(chalk.yellow(' No apps deployed yet\n'));
|
|
30
|
+
} else {
|
|
31
|
+
apps.forEach(a => console.log(` - ${a.appName}`));
|
|
32
|
+
console.log();
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If no service specified, list available services
|
|
38
|
+
if (!serviceName) {
|
|
39
|
+
try {
|
|
40
|
+
const output = await uncloudRunner.serviceList(appName);
|
|
41
|
+
|
|
42
|
+
// Parse table output - skip header line and extract service names
|
|
43
|
+
const lines = output.trim().split('\n');
|
|
44
|
+
const services: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const line = lines[i].trim();
|
|
48
|
+
// Skip header and empty lines
|
|
49
|
+
if (!line || line.startsWith('NAME') || line.startsWith('Connecting') || line.startsWith('Connected')) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Extract first column (service name)
|
|
53
|
+
const svcName = line.split(/\s+/)[0];
|
|
54
|
+
if (svcName) {
|
|
55
|
+
services.push(svcName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (services.length === 0) {
|
|
60
|
+
console.log(chalk.yellow(`\n No services found in app '${appName}'\n`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(chalk.cyan(`\n Available services in '${appName}':\n`));
|
|
65
|
+
services.forEach(s => console.log(` ${chalk.bold(s)}`));
|
|
66
|
+
console.log(chalk.dim(`\nUsage: hackerrun logs ${appName} <service>`));
|
|
67
|
+
console.log(chalk.dim(`Example: hackerrun logs ${appName} ${services[0]}\n`));
|
|
68
|
+
return;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(chalk.red('\n Failed to list services'));
|
|
71
|
+
console.error(chalk.yellow('Make sure the app is deployed and running\n'));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log(chalk.cyan(`\n Viewing logs for '${serviceName}' in app '${appName}'...\n`));
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await uncloudRunner.logs(appName, serviceName, {
|
|
80
|
+
follow: options.follow,
|
|
81
|
+
tail: options.tail ? parseInt(options.tail) : undefined,
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error(chalk.red(`\n Failed to fetch logs for service '${serviceName}'`));
|
|
85
|
+
console.error(chalk.yellow('The service may not exist or may not be running\n'));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
} finally {
|
|
92
|
+
uncloudRunner.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return cmd;
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { createLoginCommand } from './commands/login.js';
|
|
3
|
+
import { createDeployCommand } from './commands/deploy.js';
|
|
4
|
+
import { createAppCommands } from './commands/app.js';
|
|
5
|
+
import { createConfigCommand } from './commands/config.js';
|
|
6
|
+
import { createLogsCommand } from './commands/logs.js';
|
|
7
|
+
import { createEnvCommand } from './commands/env.js';
|
|
8
|
+
import { createConnectCommand, createDisconnectCommand } from './commands/connect.js';
|
|
9
|
+
import { createBuildsCommand } from './commands/builds.js';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('hackerrun')
|
|
15
|
+
.description('Deploy apps with full control over your infrastructure')
|
|
16
|
+
.version('0.1.0');
|
|
17
|
+
|
|
18
|
+
// Register auth command (should be first)
|
|
19
|
+
program.addCommand(createLoginCommand());
|
|
20
|
+
|
|
21
|
+
// Register config command
|
|
22
|
+
program.addCommand(createConfigCommand());
|
|
23
|
+
|
|
24
|
+
// Register PaaS commands
|
|
25
|
+
program.addCommand(createDeployCommand());
|
|
26
|
+
program.addCommand(createLogsCommand());
|
|
27
|
+
|
|
28
|
+
// Register CI/CD commands
|
|
29
|
+
program.addCommand(createConnectCommand());
|
|
30
|
+
program.addCommand(createDisconnectCommand());
|
|
31
|
+
program.addCommand(createEnvCommand());
|
|
32
|
+
program.addCommand(createBuildsCommand());
|
|
33
|
+
|
|
34
|
+
const { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
|
|
35
|
+
program.addCommand(appsCmd);
|
|
36
|
+
program.addCommand(nodesCmd);
|
|
37
|
+
program.addCommand(sshCmd);
|
|
38
|
+
program.addCommand(destroyCmd);
|
|
39
|
+
program.addCommand(linkCmd);
|
|
40
|
+
program.addCommand(renameCmd);
|
|
41
|
+
program.addCommand(domainCmd);
|
|
42
|
+
|
|
43
|
+
program.parse();
|