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
package/dist/index.js
ADDED
|
@@ -0,0 +1,2813 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import { Command as Command9 } from "commander";
|
|
11
|
+
|
|
12
|
+
// src/commands/login.ts
|
|
13
|
+
import { Command } from "commander";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import ora from "ora";
|
|
16
|
+
|
|
17
|
+
// src/lib/config.ts
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
var ConfigManager = class {
|
|
22
|
+
configDir;
|
|
23
|
+
configFile;
|
|
24
|
+
constructor() {
|
|
25
|
+
this.configDir = join(homedir(), ".config", "hackerrun");
|
|
26
|
+
this.configFile = join(this.configDir, "config.json");
|
|
27
|
+
this.ensureConfigDir();
|
|
28
|
+
}
|
|
29
|
+
ensureConfigDir() {
|
|
30
|
+
if (!existsSync(this.configDir)) {
|
|
31
|
+
mkdirSync(this.configDir, { recursive: true, mode: 448 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Read config from ~/.config/hackerrun/config.json
|
|
36
|
+
*/
|
|
37
|
+
readConfig() {
|
|
38
|
+
if (!existsSync(this.configFile)) {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const content = readFileSync(this.configFile, "utf-8");
|
|
43
|
+
return JSON.parse(content);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.warn("Failed to read config file");
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Write config to ~/.config/hackerrun/config.json
|
|
51
|
+
*/
|
|
52
|
+
writeConfig(config) {
|
|
53
|
+
writeFileSync(this.configFile, JSON.stringify(config, null, 2), { mode: 384 });
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Load and validate config
|
|
57
|
+
*/
|
|
58
|
+
load() {
|
|
59
|
+
const config = this.readConfig();
|
|
60
|
+
if (!config.apiToken) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Missing API token. Please run:
|
|
63
|
+
|
|
64
|
+
hackerrun login
|
|
65
|
+
`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
apiToken: config.apiToken
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Set a config value
|
|
74
|
+
*/
|
|
75
|
+
set(key, value) {
|
|
76
|
+
const config = this.readConfig();
|
|
77
|
+
config[key] = value;
|
|
78
|
+
this.writeConfig(config);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get a config value
|
|
82
|
+
*/
|
|
83
|
+
get(key) {
|
|
84
|
+
const config = this.readConfig();
|
|
85
|
+
return config[key];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get all config values (with sensitive data masked)
|
|
89
|
+
*/
|
|
90
|
+
getAll(maskSensitive = true) {
|
|
91
|
+
const config = this.readConfig();
|
|
92
|
+
if (maskSensitive && config.apiToken) {
|
|
93
|
+
config.apiToken = this.maskToken(config.apiToken);
|
|
94
|
+
}
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Mask sensitive token for display
|
|
99
|
+
*/
|
|
100
|
+
maskToken(token) {
|
|
101
|
+
if (token.length <= 8) return "***";
|
|
102
|
+
return token.substring(0, 8) + "..." + token.substring(token.length - 4);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Delete a config value
|
|
106
|
+
*/
|
|
107
|
+
unset(key) {
|
|
108
|
+
const config = this.readConfig();
|
|
109
|
+
delete config[key];
|
|
110
|
+
this.writeConfig(config);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Check if config exists and is valid
|
|
114
|
+
*/
|
|
115
|
+
exists() {
|
|
116
|
+
const config = this.readConfig();
|
|
117
|
+
return !!config.apiToken;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get config file path
|
|
121
|
+
*/
|
|
122
|
+
getConfigPath() {
|
|
123
|
+
return this.configFile;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/commands/login.ts
|
|
128
|
+
var PLATFORM_API_URL = process.env.HACKERRUN_API_URL || "http://localhost:3000";
|
|
129
|
+
function createLoginCommand() {
|
|
130
|
+
const cmd = new Command("login");
|
|
131
|
+
cmd.description("Login to HackerRun platform");
|
|
132
|
+
cmd.action(async () => {
|
|
133
|
+
try {
|
|
134
|
+
console.log(chalk.cyan("\n\u{1F510} Logging in to HackerRun\n"));
|
|
135
|
+
const spinner = ora("Initiating login...").start();
|
|
136
|
+
const initResponse = await fetch(`${PLATFORM_API_URL}/api/auth/device`, {
|
|
137
|
+
method: "POST"
|
|
138
|
+
});
|
|
139
|
+
if (!initResponse.ok) {
|
|
140
|
+
spinner.fail("Failed to initiate login");
|
|
141
|
+
const error = await initResponse.json().catch(() => ({ error: "Unknown error" }));
|
|
142
|
+
console.error(chalk.red(`
|
|
143
|
+
Error: ${error.error || initResponse.statusText}
|
|
144
|
+
`));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
const deviceFlow = await initResponse.json();
|
|
148
|
+
spinner.succeed("Login initiated");
|
|
149
|
+
console.log(chalk.cyan("\n\u{1F4CB} Please complete the following steps:\n"));
|
|
150
|
+
console.log(` 1. Visit: ${chalk.bold.blue(deviceFlow.verificationUri)}`);
|
|
151
|
+
console.log(` 2. Enter code: ${chalk.bold.yellow(deviceFlow.userCode)}
|
|
152
|
+
`);
|
|
153
|
+
console.log(chalk.dim(`Code expires in ${Math.floor(deviceFlow.expiresIn / 60)} minutes
|
|
154
|
+
`));
|
|
155
|
+
const pollSpinner = ora("Waiting for authorization...").start();
|
|
156
|
+
const pollInterval = deviceFlow.interval * 1e3;
|
|
157
|
+
const maxAttempts = Math.floor(deviceFlow.expiresIn / deviceFlow.interval);
|
|
158
|
+
let attempts = 0;
|
|
159
|
+
while (attempts < maxAttempts) {
|
|
160
|
+
await sleep(pollInterval);
|
|
161
|
+
attempts++;
|
|
162
|
+
try {
|
|
163
|
+
const pollResponse = await fetch(
|
|
164
|
+
`${PLATFORM_API_URL}/api/auth/device/poll?device_code=${deviceFlow.deviceCode}`
|
|
165
|
+
);
|
|
166
|
+
if (!pollResponse.ok) {
|
|
167
|
+
pollSpinner.fail("Failed to check authorization status");
|
|
168
|
+
console.error(chalk.red("\nPlease try again later\n"));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
const result = await pollResponse.json();
|
|
172
|
+
if (result.status === "authorized") {
|
|
173
|
+
pollSpinner.succeed(chalk.green("Authorization successful!"));
|
|
174
|
+
const configManager = new ConfigManager();
|
|
175
|
+
configManager.set("apiToken", result.token);
|
|
176
|
+
console.log(chalk.green("\n\u2713 Login successful!\n"));
|
|
177
|
+
console.log(chalk.cyan("You can now use HackerRun CLI:\n"));
|
|
178
|
+
console.log(` ${chalk.bold("hackerrun deploy")} Deploy your application`);
|
|
179
|
+
console.log(` ${chalk.bold("hackerrun app list")} List your apps`);
|
|
180
|
+
console.log(` ${chalk.bold("hackerrun config list")} View configuration
|
|
181
|
+
`);
|
|
182
|
+
return;
|
|
183
|
+
} else if (result.status === "expired") {
|
|
184
|
+
pollSpinner.fail("Authorization code expired");
|
|
185
|
+
console.error(chalk.red("\nPlease run `hackerrun login` again\n"));
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
pollSpinner.fail("Failed to check authorization");
|
|
190
|
+
console.error(chalk.red(`
|
|
191
|
+
Error: ${error.message}
|
|
192
|
+
`));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
pollSpinner.fail("Authorization timed out");
|
|
197
|
+
console.error(chalk.red("\nPlease run `hackerrun login` again\n"));
|
|
198
|
+
process.exit(1);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error(chalk.red(`
|
|
201
|
+
\u274C Error: ${error.message}
|
|
202
|
+
`));
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
return cmd;
|
|
207
|
+
}
|
|
208
|
+
function sleep(ms) {
|
|
209
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/commands/deploy.ts
|
|
213
|
+
import { Command as Command2 } from "commander";
|
|
214
|
+
import chalk7 from "chalk";
|
|
215
|
+
import ora4 from "ora";
|
|
216
|
+
|
|
217
|
+
// src/lib/ssh-cert.ts
|
|
218
|
+
import { execSync } from "child_process";
|
|
219
|
+
import { mkdtempSync, writeFileSync as writeFileSync2, readFileSync as readFileSync2, rmSync, existsSync as existsSync2, chmodSync } from "fs";
|
|
220
|
+
import { join as join2 } from "path";
|
|
221
|
+
import { tmpdir } from "os";
|
|
222
|
+
import * as net from "net";
|
|
223
|
+
var SSHCertManager = class {
|
|
224
|
+
constructor(platformClient) {
|
|
225
|
+
this.platformClient = platformClient;
|
|
226
|
+
}
|
|
227
|
+
sessions = /* @__PURE__ */ new Map();
|
|
228
|
+
sshAgentStarted = false;
|
|
229
|
+
/**
|
|
230
|
+
* Get or create an SSH session for an app
|
|
231
|
+
* Generates temp keypair, gets certificate, adds to SSH agent
|
|
232
|
+
*/
|
|
233
|
+
async getSession(appName, vmIp) {
|
|
234
|
+
const existingSession = this.sessions.get(appName);
|
|
235
|
+
if (existingSession && this.isSessionValid(existingSession)) {
|
|
236
|
+
return existingSession;
|
|
237
|
+
}
|
|
238
|
+
const session = await this.createSession(appName, vmIp);
|
|
239
|
+
this.sessions.set(appName, session);
|
|
240
|
+
return session;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Create a new SSH session with certificate
|
|
244
|
+
*/
|
|
245
|
+
async createSession(appName, vmIp) {
|
|
246
|
+
const tmpDir = mkdtempSync(join2(tmpdir(), "hackerrun-ssh-"));
|
|
247
|
+
const keyPath = join2(tmpDir, "key");
|
|
248
|
+
try {
|
|
249
|
+
execSync(`ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "hackerrun-temp"`, {
|
|
250
|
+
stdio: "pipe"
|
|
251
|
+
});
|
|
252
|
+
const publicKey = readFileSync2(`${keyPath}.pub`, "utf8").trim();
|
|
253
|
+
const { certificate } = await this.platformClient.requestSSHCertificate(appName, publicKey);
|
|
254
|
+
const certPath = `${keyPath}-cert.pub`;
|
|
255
|
+
writeFileSync2(certPath, certificate);
|
|
256
|
+
chmodSync(keyPath, 384);
|
|
257
|
+
await this.addToSSHAgent(keyPath);
|
|
258
|
+
const session = {
|
|
259
|
+
keyPath,
|
|
260
|
+
publicKey,
|
|
261
|
+
certificate,
|
|
262
|
+
certPath,
|
|
263
|
+
vmIp,
|
|
264
|
+
cleanup: () => {
|
|
265
|
+
try {
|
|
266
|
+
execSync(`ssh-add -d "${keyPath}" 2>/dev/null`, { stdio: "pipe" });
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
270
|
+
this.sessions.delete(appName);
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
return session;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Check if an SSH agent is running, start one if needed
|
|
281
|
+
*/
|
|
282
|
+
ensureSSHAgent() {
|
|
283
|
+
if (this.sshAgentStarted) return;
|
|
284
|
+
const agentPid = process.env.SSH_AGENT_PID;
|
|
285
|
+
const authSock = process.env.SSH_AUTH_SOCK;
|
|
286
|
+
if (agentPid && authSock && existsSync2(authSock)) {
|
|
287
|
+
this.sshAgentStarted = true;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const result = execSync("ssh-agent -s", { encoding: "utf-8" });
|
|
292
|
+
const pidMatch = result.match(/SSH_AGENT_PID=(\d+)/);
|
|
293
|
+
const sockMatch = result.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
294
|
+
if (pidMatch && sockMatch) {
|
|
295
|
+
process.env.SSH_AGENT_PID = pidMatch[1];
|
|
296
|
+
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
297
|
+
this.sshAgentStarted = true;
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Add key and certificate to SSH agent
|
|
304
|
+
*/
|
|
305
|
+
async addToSSHAgent(keyPath) {
|
|
306
|
+
this.ensureSSHAgent();
|
|
307
|
+
try {
|
|
308
|
+
execSync(`ssh-add "${keyPath}"`, { stdio: "pipe" });
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`Failed to add key to SSH agent: ${error.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Check if a session is still valid (certificate not expired)
|
|
315
|
+
* Certificates have 5-minute TTL
|
|
316
|
+
*/
|
|
317
|
+
isSessionValid(session) {
|
|
318
|
+
try {
|
|
319
|
+
if (!existsSync2(session.keyPath) || !existsSync2(session.certPath)) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
const result = execSync(`ssh-keygen -L -f "${session.certPath}"`, { encoding: "utf-8" });
|
|
323
|
+
const validMatch = result.match(/Valid: from .* to (.+)/);
|
|
324
|
+
if (!validMatch) return false;
|
|
325
|
+
const expiryStr = validMatch[1].trim();
|
|
326
|
+
const expiry = new Date(expiryStr);
|
|
327
|
+
const now = /* @__PURE__ */ new Date();
|
|
328
|
+
now.setSeconds(now.getSeconds() + 30);
|
|
329
|
+
return expiry > now;
|
|
330
|
+
} catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Get SSH command options for connecting with certificate
|
|
336
|
+
* Returns options that work with ssh+cli:// connector
|
|
337
|
+
*/
|
|
338
|
+
getSSHOptions(session) {
|
|
339
|
+
return [
|
|
340
|
+
"-o",
|
|
341
|
+
"StrictHostKeyChecking=no",
|
|
342
|
+
"-o",
|
|
343
|
+
"UserKnownHostsFile=/dev/null",
|
|
344
|
+
"-o",
|
|
345
|
+
`IdentityFile=${session.keyPath}`,
|
|
346
|
+
"-o",
|
|
347
|
+
`CertificateFile=${session.certPath}`
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Format VM IP for uncloud --connect ssh+cli:// URL
|
|
352
|
+
* Note: Don't use brackets - uncloud passes this to SSH which expects raw addresses
|
|
353
|
+
*/
|
|
354
|
+
formatConnectionURL(vmIp) {
|
|
355
|
+
return `ssh+cli://root@${vmIp}`;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Clean up all sessions
|
|
359
|
+
*/
|
|
360
|
+
cleanupAll() {
|
|
361
|
+
for (const session of this.sessions.values()) {
|
|
362
|
+
session.cleanup();
|
|
363
|
+
}
|
|
364
|
+
this.sessions.clear();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
var ipv6ConnectivityCache = /* @__PURE__ */ new Map();
|
|
368
|
+
async function testIPv6Connectivity(vmIp, timeoutMs = 2e3) {
|
|
369
|
+
if (ipv6ConnectivityCache.has(vmIp)) {
|
|
370
|
+
return ipv6ConnectivityCache.get(vmIp);
|
|
371
|
+
}
|
|
372
|
+
const result = await new Promise((resolve) => {
|
|
373
|
+
const socket = new net.Socket();
|
|
374
|
+
const cleanup = () => {
|
|
375
|
+
socket.destroy();
|
|
376
|
+
};
|
|
377
|
+
socket.setTimeout(timeoutMs);
|
|
378
|
+
socket.on("connect", () => {
|
|
379
|
+
cleanup();
|
|
380
|
+
resolve(true);
|
|
381
|
+
});
|
|
382
|
+
socket.on("timeout", () => {
|
|
383
|
+
cleanup();
|
|
384
|
+
resolve(false);
|
|
385
|
+
});
|
|
386
|
+
socket.on("error", () => {
|
|
387
|
+
cleanup();
|
|
388
|
+
resolve(false);
|
|
389
|
+
});
|
|
390
|
+
socket.connect(22, vmIp);
|
|
391
|
+
});
|
|
392
|
+
ipv6ConnectivityCache.set(vmIp, result);
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/lib/cluster.ts
|
|
397
|
+
import { execSync as execSync2 } from "child_process";
|
|
398
|
+
import ora2 from "ora";
|
|
399
|
+
import chalk2 from "chalk";
|
|
400
|
+
var platformKeysCache = null;
|
|
401
|
+
var ClusterManager = class {
|
|
402
|
+
constructor(platformClient) {
|
|
403
|
+
this.platformClient = platformClient;
|
|
404
|
+
this.sshCertManager = new SSHCertManager(platformClient);
|
|
405
|
+
}
|
|
406
|
+
sshCertManager;
|
|
407
|
+
/**
|
|
408
|
+
* Get platform SSH keys (cached for the session)
|
|
409
|
+
* Returns CA public key and platform public key for VM creation
|
|
410
|
+
*/
|
|
411
|
+
async getPlatformKeys() {
|
|
412
|
+
if (platformKeysCache) {
|
|
413
|
+
return platformKeysCache;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
platformKeysCache = await this.platformClient.getPlatformSSHKeys();
|
|
417
|
+
return platformKeysCache;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
throw new Error(`Failed to get platform SSH keys: ${error.message}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Initialize a new cluster for an app (creates first VM and sets up uncloud)
|
|
424
|
+
*
|
|
425
|
+
* Flow:
|
|
426
|
+
* 1. Create VM with platform SSH key
|
|
427
|
+
* 2. Wait for VM to get IPv6 address
|
|
428
|
+
* 3. Call platform API to setup VM (DNS64, WireGuard, SSH CA)
|
|
429
|
+
* 4. Run `uc machine init` to install Docker + uncloud
|
|
430
|
+
* 5. Configure Docker for NAT64
|
|
431
|
+
* 6. Save app state to platform
|
|
432
|
+
*
|
|
433
|
+
* Users access VMs via SSH certificates signed by the platform CA.
|
|
434
|
+
*/
|
|
435
|
+
async initializeCluster(options) {
|
|
436
|
+
const { appName, location, vmSize, storageSize, bootImage } = options;
|
|
437
|
+
const vmName = `${appName}-vm-${this.generateId()}`;
|
|
438
|
+
let spinner = ora2(`Creating VM '${vmName}' in ${location}...`).start();
|
|
439
|
+
try {
|
|
440
|
+
spinner.text = "Fetching platform SSH keys...";
|
|
441
|
+
const platformKeys = await this.getPlatformKeys();
|
|
442
|
+
const gateway = await this.platformClient.getGateway(location);
|
|
443
|
+
const privateSubnetId = gateway?.subnetId;
|
|
444
|
+
spinner.text = `Creating VM '${vmName}'...`;
|
|
445
|
+
const vm = await this.platformClient.createVM({
|
|
446
|
+
name: vmName,
|
|
447
|
+
location,
|
|
448
|
+
size: vmSize,
|
|
449
|
+
storage_size: storageSize,
|
|
450
|
+
boot_image: bootImage,
|
|
451
|
+
unix_user: "root",
|
|
452
|
+
public_key: platformKeys.platformPublicKey,
|
|
453
|
+
enable_ip4: !privateSubnetId,
|
|
454
|
+
// IPv6-only if we have a subnet for NAT64
|
|
455
|
+
private_subnet_id: privateSubnetId
|
|
456
|
+
});
|
|
457
|
+
spinner.text = `Waiting for VM to be ready...`;
|
|
458
|
+
spinner.stop();
|
|
459
|
+
console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
|
|
460
|
+
const vmWithIp = await this.waitForVM(location, vmName, 600, false);
|
|
461
|
+
spinner = ora2("Setting up VM...").start();
|
|
462
|
+
spinner.text = "Waiting for SSH to be ready...";
|
|
463
|
+
await this.sleep(3e4);
|
|
464
|
+
spinner.text = "Configuring VM (DNS64, NAT64, SSH CA)...";
|
|
465
|
+
await this.platformClient.setupVM(vmWithIp.ip6, location, appName);
|
|
466
|
+
const cluster = {
|
|
467
|
+
appName,
|
|
468
|
+
location,
|
|
469
|
+
nodes: [{
|
|
470
|
+
name: vmName,
|
|
471
|
+
id: vm.id,
|
|
472
|
+
ipv6: vmWithIp.ip6,
|
|
473
|
+
isPrimary: true
|
|
474
|
+
}],
|
|
475
|
+
uncloudContext: appName,
|
|
476
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
477
|
+
};
|
|
478
|
+
spinner.text = "Registering app...";
|
|
479
|
+
const savedCluster = await this.platformClient.saveApp(cluster);
|
|
480
|
+
spinner.text = "Installing Docker and Uncloud...";
|
|
481
|
+
spinner.stop();
|
|
482
|
+
console.log(chalk2.cyan("\nInitializing uncloud (this may take a few minutes)..."));
|
|
483
|
+
await this.initializeUncloud(vmWithIp.ip6, appName);
|
|
484
|
+
spinner = ora2("Configuring Docker for NAT64...").start();
|
|
485
|
+
await this.configureDockerNAT64(vmWithIp.ip6);
|
|
486
|
+
spinner.succeed(chalk2.green(`Cluster initialized successfully`));
|
|
487
|
+
return savedCluster;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
spinner.fail(chalk2.red("Failed to initialize cluster"));
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Add a node to an existing cluster
|
|
495
|
+
*/
|
|
496
|
+
async addNode(appName, vmSize, bootImage) {
|
|
497
|
+
const cluster = await this.platformClient.getApp(appName);
|
|
498
|
+
if (!cluster) {
|
|
499
|
+
throw new Error(`App '${appName}' not found`);
|
|
500
|
+
}
|
|
501
|
+
const primaryNode = await this.platformClient.getPrimaryNode(appName);
|
|
502
|
+
if (!primaryNode || !primaryNode.ipv6) {
|
|
503
|
+
throw new Error(`Primary node not found or has no IPv6 address`);
|
|
504
|
+
}
|
|
505
|
+
const gateway = await this.platformClient.getGateway(cluster.location);
|
|
506
|
+
const privateSubnetId = gateway?.subnetId;
|
|
507
|
+
const vmName = `${appName}-vm-${this.generateId()}`;
|
|
508
|
+
let spinner = ora2(`Adding node '${vmName}' to cluster...`).start();
|
|
509
|
+
try {
|
|
510
|
+
spinner.text = "Fetching platform SSH keys...";
|
|
511
|
+
const platformKeys = await this.getPlatformKeys();
|
|
512
|
+
spinner.text = `Creating VM '${vmName}'...`;
|
|
513
|
+
const vm = await this.platformClient.createVM({
|
|
514
|
+
name: vmName,
|
|
515
|
+
location: cluster.location,
|
|
516
|
+
size: vmSize,
|
|
517
|
+
boot_image: bootImage,
|
|
518
|
+
unix_user: "root",
|
|
519
|
+
public_key: platformKeys.platformPublicKey,
|
|
520
|
+
enable_ip4: !privateSubnetId,
|
|
521
|
+
private_subnet_id: privateSubnetId
|
|
522
|
+
});
|
|
523
|
+
spinner.text = `Waiting for VM to be ready...`;
|
|
524
|
+
spinner.stop();
|
|
525
|
+
console.log(chalk2.cyan("\nWaiting for VM to get an IPv6 address..."));
|
|
526
|
+
const vmWithIp = await this.waitForVM(cluster.location, vmName, 600, false);
|
|
527
|
+
spinner = ora2("Setting up VM...").start();
|
|
528
|
+
await this.sleep(3e4);
|
|
529
|
+
spinner.text = "Configuring VM...";
|
|
530
|
+
await this.platformClient.setupVM(vmWithIp.ip6, cluster.location, appName);
|
|
531
|
+
spinner.text = "Joining uncloud cluster...";
|
|
532
|
+
spinner.stop();
|
|
533
|
+
console.log(chalk2.cyan("\nJoining uncloud cluster..."));
|
|
534
|
+
await this.joinUncloudCluster(vmWithIp.ip6, primaryNode.ipv6, appName);
|
|
535
|
+
spinner = ora2("Configuring Docker for NAT64...").start();
|
|
536
|
+
await this.configureDockerNAT64(vmWithIp.ip6);
|
|
537
|
+
spinner.succeed(chalk2.green(`Node '${vmName}' added successfully`));
|
|
538
|
+
const newNode = {
|
|
539
|
+
name: vmName,
|
|
540
|
+
id: vm.id,
|
|
541
|
+
ipv6: vmWithIp.ip6,
|
|
542
|
+
isPrimary: false
|
|
543
|
+
};
|
|
544
|
+
cluster.nodes.push(newNode);
|
|
545
|
+
await this.platformClient.saveApp(cluster);
|
|
546
|
+
return newNode;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
spinner.fail(chalk2.red("Failed to add node"));
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Initialize uncloud on the VM using uc machine init
|
|
554
|
+
* Uses --no-dns because we manage our own domain via gateway
|
|
555
|
+
*
|
|
556
|
+
* Before running uc, we get an SSH certificate from the platform
|
|
557
|
+
* and add it to the ssh-agent. This allows uc to authenticate
|
|
558
|
+
* since the VM only accepts platform SSH key or signed certificates.
|
|
559
|
+
*/
|
|
560
|
+
async initializeUncloud(vmIp, contextName) {
|
|
561
|
+
try {
|
|
562
|
+
await this.sshCertManager.getSession(contextName, vmIp);
|
|
563
|
+
execSync2(`uc machine init -c "${contextName}" --no-dns root@${vmIp}`, {
|
|
564
|
+
stdio: "inherit",
|
|
565
|
+
timeout: 6e5
|
|
566
|
+
// 10 min timeout
|
|
567
|
+
});
|
|
568
|
+
} catch (error) {
|
|
569
|
+
throw new Error(`Failed to initialize uncloud: ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Join a new node to an existing uncloud cluster
|
|
574
|
+
* Uses --connect ssh:// to avoid local context dependency
|
|
575
|
+
*/
|
|
576
|
+
async joinUncloudCluster(newVmIp, primaryVmIp, contextName) {
|
|
577
|
+
try {
|
|
578
|
+
await this.sshCertManager.getSession(contextName, primaryVmIp);
|
|
579
|
+
await this.sshCertManager.getSession(contextName, newVmIp);
|
|
580
|
+
const token = execSync2(`uc --connect ssh://root@${primaryVmIp} machine token`, {
|
|
581
|
+
encoding: "utf-8",
|
|
582
|
+
timeout: 3e4
|
|
583
|
+
}).trim();
|
|
584
|
+
if (!token) {
|
|
585
|
+
throw new Error("Failed to get join token from primary node");
|
|
586
|
+
}
|
|
587
|
+
execSync2(`uc --connect ssh://root@${primaryVmIp} machine add root@${newVmIp} --token "${token}"`, {
|
|
588
|
+
stdio: "inherit",
|
|
589
|
+
timeout: 6e5
|
|
590
|
+
// 10 min timeout
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
throw new Error(`Failed to join cluster: ${error.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Configure Docker for NAT64 support on IPv6-only VMs
|
|
598
|
+
*
|
|
599
|
+
* Problem: DNS64 returns both A (IPv4) and AAAA (IPv6) records. Applications may try
|
|
600
|
+
* IPv4 first, which times out because there's no route to IPv4 internet.
|
|
601
|
+
*
|
|
602
|
+
* Solution:
|
|
603
|
+
* 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
|
|
604
|
+
* 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
|
|
605
|
+
* 3. Applications then use IPv6 (NAT64) which works via the gateway
|
|
606
|
+
*/
|
|
607
|
+
async configureDockerNAT64(vmIp) {
|
|
608
|
+
const setupScript = `#!/bin/bash
|
|
609
|
+
set -e
|
|
610
|
+
|
|
611
|
+
# Step 1: Add IPv6 support to Docker daemon config
|
|
612
|
+
DAEMON_JSON='/etc/docker/daemon.json'
|
|
613
|
+
if [ -f "$DAEMON_JSON" ]; then
|
|
614
|
+
# Merge with existing config using jq
|
|
615
|
+
jq '. + {"ipv6": true, "fixed-cidr-v6": "fd00:d0c6:e4::/64"}' "$DAEMON_JSON" > /tmp/daemon.json
|
|
616
|
+
mv /tmp/daemon.json "$DAEMON_JSON"
|
|
617
|
+
else
|
|
618
|
+
cat > "$DAEMON_JSON" << 'EOFJSON'
|
|
619
|
+
{
|
|
620
|
+
"ipv6": true,
|
|
621
|
+
"fixed-cidr-v6": "fd00:d0c6:e4::/64"
|
|
622
|
+
}
|
|
623
|
+
EOFJSON
|
|
624
|
+
fi
|
|
625
|
+
|
|
626
|
+
# Step 2: Create systemd service to block IPv4 forwarding from Docker containers
|
|
627
|
+
cat > /etc/systemd/system/docker-ipv6-nat64.service << 'EOFSVC'
|
|
628
|
+
[Unit]
|
|
629
|
+
Description=Block IPv4 forwarding from Docker containers (force NAT64)
|
|
630
|
+
After=docker.service
|
|
631
|
+
Requires=docker.service
|
|
632
|
+
|
|
633
|
+
[Service]
|
|
634
|
+
Type=oneshot
|
|
635
|
+
RemainAfterExit=yes
|
|
636
|
+
ExecStart=/sbin/iptables -I FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
|
|
637
|
+
ExecStop=/sbin/iptables -D FORWARD -i br-+ -o ens3 -j REJECT --reject-with icmp-net-unreachable
|
|
638
|
+
|
|
639
|
+
[Install]
|
|
640
|
+
WantedBy=multi-user.target
|
|
641
|
+
EOFSVC
|
|
642
|
+
|
|
643
|
+
systemctl daemon-reload
|
|
644
|
+
systemctl enable docker-ipv6-nat64
|
|
645
|
+
|
|
646
|
+
# Step 3: Restart Docker to apply IPv6 config
|
|
647
|
+
systemctl restart docker
|
|
648
|
+
sleep 3
|
|
649
|
+
|
|
650
|
+
# Step 4: Recreate uncloud network with IPv6 enabled
|
|
651
|
+
CONTAINERS=$(docker ps -aq --filter network=uncloud 2>/dev/null || true)
|
|
652
|
+
|
|
653
|
+
for container in $CONTAINERS; do
|
|
654
|
+
docker network disconnect uncloud "$container" 2>/dev/null || true
|
|
655
|
+
done
|
|
656
|
+
|
|
657
|
+
docker network rm uncloud 2>/dev/null || true
|
|
658
|
+
docker network create --driver bridge --subnet 10.210.0.0/24 --gateway 10.210.0.1 --ipv6 --subnet fd00:a10:210::/64 --gateway fd00:a10:210::1 uncloud
|
|
659
|
+
|
|
660
|
+
for container in $CONTAINERS; do
|
|
661
|
+
docker network connect uncloud "$container" 2>/dev/null || true
|
|
662
|
+
done
|
|
663
|
+
|
|
664
|
+
# Step 5: Start the iptables service
|
|
665
|
+
systemctl start docker-ipv6-nat64
|
|
666
|
+
|
|
667
|
+
echo "Docker NAT64 configuration complete"
|
|
668
|
+
`;
|
|
669
|
+
try {
|
|
670
|
+
execSync2(`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${vmIp} 'bash -s' << 'REMOTESCRIPT'
|
|
671
|
+
${setupScript}
|
|
672
|
+
REMOTESCRIPT`, {
|
|
673
|
+
stdio: "inherit",
|
|
674
|
+
timeout: 12e4
|
|
675
|
+
// 2 min timeout
|
|
676
|
+
});
|
|
677
|
+
} catch (error) {
|
|
678
|
+
throw new Error(`Failed to configure Docker for NAT64: ${error.message}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Wait for VM to have an IP address
|
|
683
|
+
* @param requireIpv4 - If true, wait for IPv4 (for gateway VMs). Otherwise wait for IPv6.
|
|
684
|
+
*/
|
|
685
|
+
async waitForVM(location, vmName, timeoutSeconds, requireIpv4 = false) {
|
|
686
|
+
const startTime = Date.now();
|
|
687
|
+
const timeoutMs = timeoutSeconds * 1e3;
|
|
688
|
+
let lastStatus = "";
|
|
689
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
690
|
+
const vm = await this.platformClient.getVM(location, vmName);
|
|
691
|
+
if (vm.status && vm.status !== lastStatus) {
|
|
692
|
+
console.log(chalk2.dim(` Status: ${vm.status}`));
|
|
693
|
+
lastStatus = vm.status;
|
|
694
|
+
}
|
|
695
|
+
const hasRequiredIp = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
696
|
+
if (hasRequiredIp) {
|
|
697
|
+
const ipDisplay = requireIpv4 ? vm.ip4 : vm.ip6;
|
|
698
|
+
console.log(chalk2.green(` IP assigned: ${ipDisplay}`));
|
|
699
|
+
return vm;
|
|
700
|
+
}
|
|
701
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
702
|
+
const ipType = requireIpv4 ? "IPv4" : "IPv6";
|
|
703
|
+
process.stdout.write(`\r Waiting for ${ipType}... (${elapsed}s / ${timeoutSeconds}s)`);
|
|
704
|
+
await this.sleep(5e3);
|
|
705
|
+
}
|
|
706
|
+
process.stdout.write("\n");
|
|
707
|
+
throw new Error(
|
|
708
|
+
`Timeout waiting for VM to get IP address after ${timeoutSeconds}s.
|
|
709
|
+
|
|
710
|
+
The VM may still be provisioning. You can:
|
|
711
|
+
1. Check VM status: hackerrun vm list
|
|
712
|
+
2. Delete and retry: hackerrun vm delete ${vmName}
|
|
713
|
+
3. Check Ubicloud console: https://console.ubicloud.com`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Generate a random ID
|
|
718
|
+
*/
|
|
719
|
+
generateId() {
|
|
720
|
+
return Math.random().toString(36).substring(2, 9);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Sleep for specified milliseconds
|
|
724
|
+
*/
|
|
725
|
+
sleep(ms) {
|
|
726
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// src/lib/platform.ts
|
|
731
|
+
import { platform } from "os";
|
|
732
|
+
import chalk3 from "chalk";
|
|
733
|
+
var PlatformDetector = class {
|
|
734
|
+
/**
|
|
735
|
+
* Check if running on Windows
|
|
736
|
+
*/
|
|
737
|
+
static isWindows() {
|
|
738
|
+
return platform() === "win32";
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Check if running inside WSL (Windows Subsystem for Linux)
|
|
742
|
+
*/
|
|
743
|
+
static isWSL() {
|
|
744
|
+
if (!this.isWindows()) {
|
|
745
|
+
if (platform() === "linux") {
|
|
746
|
+
try {
|
|
747
|
+
const fs = __require("fs");
|
|
748
|
+
const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase();
|
|
749
|
+
return procVersion.includes("microsoft") || procVersion.includes("wsl");
|
|
750
|
+
} catch {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Check if platform is supported for hackerrun
|
|
760
|
+
*/
|
|
761
|
+
static isSupported() {
|
|
762
|
+
return platform() === "darwin" || platform() === "linux" || this.isWSL();
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Get platform name for display
|
|
766
|
+
*/
|
|
767
|
+
static getPlatformName() {
|
|
768
|
+
if (this.isWSL()) return "WSL (Windows Subsystem for Linux)";
|
|
769
|
+
if (platform() === "win32") return "Windows";
|
|
770
|
+
if (platform() === "darwin") return "macOS";
|
|
771
|
+
if (platform() === "linux") return "Linux";
|
|
772
|
+
return platform();
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Ensure platform is supported, exit with helpful message if not
|
|
776
|
+
*/
|
|
777
|
+
static ensureSupported() {
|
|
778
|
+
if (this.isWindows() && !this.isWSL()) {
|
|
779
|
+
console.log(chalk3.yellow("\n\u26A0\uFE0F Windows detected\n"));
|
|
780
|
+
console.log("Hackerrun requires WSL (Windows Subsystem for Linux) to run.");
|
|
781
|
+
console.log("Uncloud and SSH tools work best in a Linux environment.\n");
|
|
782
|
+
console.log(chalk3.cyan("How to set up WSL:\n"));
|
|
783
|
+
console.log("1. Open PowerShell as Administrator and run:");
|
|
784
|
+
console.log(chalk3.bold(" wsl --install\n"));
|
|
785
|
+
console.log("2. Restart your computer\n");
|
|
786
|
+
console.log("3. Install Ubuntu from Microsoft Store (or use default Linux)\n");
|
|
787
|
+
console.log("4. Open WSL terminal and install hackerrun:");
|
|
788
|
+
console.log(chalk3.bold(" npm install -g hackerrun\n"));
|
|
789
|
+
console.log("Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n");
|
|
790
|
+
console.log(chalk3.red("Please install WSL and run hackerrun from WSL terminal.\n"));
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
if (!this.isSupported()) {
|
|
794
|
+
console.log(chalk3.red(`
|
|
795
|
+
\u274C Platform '${platform()}' is not supported
|
|
796
|
+
`));
|
|
797
|
+
console.log("Hackerrun supports:");
|
|
798
|
+
console.log(" - macOS");
|
|
799
|
+
console.log(" - Linux");
|
|
800
|
+
console.log(" - Windows (via WSL)\n");
|
|
801
|
+
process.exit(1);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// src/lib/uncloud.ts
|
|
807
|
+
import { execSync as execSync3 } from "child_process";
|
|
808
|
+
import { platform as platform2 } from "os";
|
|
809
|
+
import chalk4 from "chalk";
|
|
810
|
+
import ora3 from "ora";
|
|
811
|
+
var UncloudManager = class {
|
|
812
|
+
/**
|
|
813
|
+
* Check if uncloud CLI is installed
|
|
814
|
+
*/
|
|
815
|
+
static isInstalled() {
|
|
816
|
+
try {
|
|
817
|
+
execSync3("uc --version", { stdio: "pipe" });
|
|
818
|
+
return true;
|
|
819
|
+
} catch {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Get uncloud version
|
|
825
|
+
*/
|
|
826
|
+
static getVersion() {
|
|
827
|
+
try {
|
|
828
|
+
const version = execSync3("uc --version", { encoding: "utf-8" }).trim();
|
|
829
|
+
return version;
|
|
830
|
+
} catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Check if uncloud is installed, offer to install if not
|
|
836
|
+
*/
|
|
837
|
+
static async ensureInstalled() {
|
|
838
|
+
if (this.isInstalled()) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
console.log(chalk4.yellow("\n\u26A0\uFE0F Uncloud CLI not found\n"));
|
|
842
|
+
console.log("Uncloud is required to deploy and manage your apps.");
|
|
843
|
+
console.log("Learn more: https://uncloud.run\n");
|
|
844
|
+
const os = platform2();
|
|
845
|
+
if (os === "darwin" || os === "linux") {
|
|
846
|
+
this.showInstallInstructions();
|
|
847
|
+
console.log(chalk4.cyan("Would you like to install uncloud now? (Y/n): "));
|
|
848
|
+
const answer = await this.promptUser();
|
|
849
|
+
if (answer === "y" || answer === "yes" || answer === "") {
|
|
850
|
+
try {
|
|
851
|
+
await this.autoInstall();
|
|
852
|
+
return;
|
|
853
|
+
} catch (error) {
|
|
854
|
+
console.log(chalk4.red("\nAuto-installation failed. Please install manually.\n"));
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
} else {
|
|
858
|
+
console.log(chalk4.yellow("\nPlease install uncloud manually and run hackerrun again.\n"));
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
} else {
|
|
862
|
+
this.showInstallInstructions();
|
|
863
|
+
console.log(chalk4.red("Please install uncloud and run hackerrun again.\n"));
|
|
864
|
+
process.exit(1);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Show installation instructions based on platform
|
|
869
|
+
*/
|
|
870
|
+
static showInstallInstructions() {
|
|
871
|
+
const os = platform2();
|
|
872
|
+
console.log(chalk4.cyan("Installation instructions:\n"));
|
|
873
|
+
if (os === "darwin" || os === "linux") {
|
|
874
|
+
console.log(chalk4.bold("Option 1: Automated install (Recommended)"));
|
|
875
|
+
console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
|
|
876
|
+
console.log(chalk4.bold("Option 2: Homebrew"));
|
|
877
|
+
console.log(chalk4.green(" brew install psviderski/tap/uncloud\n"));
|
|
878
|
+
console.log(chalk4.bold("Option 3: Manual download"));
|
|
879
|
+
console.log(" Download from: https://github.com/psviderski/uncloud/releases/latest");
|
|
880
|
+
console.log(" Extract and move to /usr/local/bin\n");
|
|
881
|
+
} else if (os === "win32") {
|
|
882
|
+
console.log(chalk4.bold("For Windows (via WSL):"));
|
|
883
|
+
console.log(" Open your WSL terminal and run:");
|
|
884
|
+
console.log(chalk4.green(" curl -fsS https://get.uncloud.run/install.sh | sh\n"));
|
|
885
|
+
}
|
|
886
|
+
console.log("Documentation: https://uncloud.run/docs/getting-started/install-cli\n");
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Prompt user for input from stdin
|
|
890
|
+
*/
|
|
891
|
+
static async promptUser() {
|
|
892
|
+
return new Promise((resolve) => {
|
|
893
|
+
process.stdin.resume();
|
|
894
|
+
process.stdin.setEncoding("utf8");
|
|
895
|
+
const onData = (input) => {
|
|
896
|
+
process.stdin.pause();
|
|
897
|
+
process.stdin.removeListener("data", onData);
|
|
898
|
+
resolve(input.trim().toLowerCase());
|
|
899
|
+
};
|
|
900
|
+
process.stdin.on("data", onData);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Attempt to auto-install uncloud with user confirmation
|
|
905
|
+
*/
|
|
906
|
+
static async autoInstall() {
|
|
907
|
+
const spinner = ora3("Installing uncloud...").start();
|
|
908
|
+
try {
|
|
909
|
+
execSync3("curl -fsS https://get.uncloud.run/install.sh | sh", {
|
|
910
|
+
stdio: "inherit"
|
|
911
|
+
});
|
|
912
|
+
spinner.succeed(chalk4.green("Uncloud installed successfully!"));
|
|
913
|
+
if (this.isInstalled()) {
|
|
914
|
+
const version = this.getVersion();
|
|
915
|
+
console.log(chalk4.cyan(`\u2713 Uncloud ${version} is ready to use
|
|
916
|
+
`));
|
|
917
|
+
} else {
|
|
918
|
+
spinner.warn(chalk4.yellow("Installation completed but uc command not found in PATH"));
|
|
919
|
+
console.log(chalk4.yellow("\nYou may need to:"));
|
|
920
|
+
console.log(" 1. Restart your terminal");
|
|
921
|
+
console.log(" 2. Or run: source ~/.bashrc (or ~/.zshrc)\n");
|
|
922
|
+
throw new Error("Please restart your terminal and try again");
|
|
923
|
+
}
|
|
924
|
+
} catch (error) {
|
|
925
|
+
spinner.fail(chalk4.red("Failed to install uncloud"));
|
|
926
|
+
console.log(chalk4.red(`
|
|
927
|
+
Error: ${error.message}
|
|
928
|
+
`));
|
|
929
|
+
console.log(chalk4.yellow("Please try manual installation:"));
|
|
930
|
+
console.log(" curl -fsS https://get.uncloud.run/install.sh | sh\n");
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
// src/lib/platform-auth.ts
|
|
937
|
+
import chalk5 from "chalk";
|
|
938
|
+
function getPlatformToken() {
|
|
939
|
+
const configManager = new ConfigManager();
|
|
940
|
+
try {
|
|
941
|
+
const config = configManager.load();
|
|
942
|
+
return config.apiToken;
|
|
943
|
+
} catch (error) {
|
|
944
|
+
console.error(chalk5.red("\n Not logged in"));
|
|
945
|
+
console.log(chalk5.cyan("\nPlease login first:\n"));
|
|
946
|
+
console.log(` ${chalk5.bold("hackerrun login")}
|
|
947
|
+
`);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/lib/platform-client.ts
|
|
953
|
+
var PLATFORM_API_URL2 = process.env.HACKERRUN_API_URL || "http://localhost:3000";
|
|
954
|
+
var PlatformClient = class {
|
|
955
|
+
constructor(authToken) {
|
|
956
|
+
this.authToken = authToken;
|
|
957
|
+
}
|
|
958
|
+
async request(method, path, body) {
|
|
959
|
+
const url = `${PLATFORM_API_URL2}${path}`;
|
|
960
|
+
const headers = {
|
|
961
|
+
Authorization: `Bearer ${this.authToken}`,
|
|
962
|
+
"Content-Type": "application/json"
|
|
963
|
+
// FIX STALE CONNECTION: If retry logic below doesn't reliably fix
|
|
964
|
+
// "SocketError: other side closed" errors, uncomment this line and
|
|
965
|
+
// remove the retry logic in the catch block below.
|
|
966
|
+
// 'Connection': 'close',
|
|
967
|
+
};
|
|
968
|
+
const doFetch = () => fetch(url, {
|
|
969
|
+
method,
|
|
970
|
+
headers,
|
|
971
|
+
body: body ? JSON.stringify(body) : void 0
|
|
972
|
+
});
|
|
973
|
+
let response;
|
|
974
|
+
try {
|
|
975
|
+
response = await doFetch();
|
|
976
|
+
} catch (error) {
|
|
977
|
+
const isSocketError = error instanceof Error && error.cause?.code === "UND_ERR_SOCKET";
|
|
978
|
+
if (isSocketError) {
|
|
979
|
+
response = await doFetch();
|
|
980
|
+
} else {
|
|
981
|
+
const errMsg = error instanceof Error ? error.message : "Unknown error";
|
|
982
|
+
throw new Error(`Failed to connect to platform API (${url}): ${errMsg}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (!response.ok) {
|
|
986
|
+
const errorText = await response.text();
|
|
987
|
+
let errorMessage;
|
|
988
|
+
try {
|
|
989
|
+
const errorJson = JSON.parse(errorText);
|
|
990
|
+
errorMessage = errorJson.error || response.statusText;
|
|
991
|
+
} catch {
|
|
992
|
+
errorMessage = errorText || response.statusText;
|
|
993
|
+
}
|
|
994
|
+
throw new Error(`API error (${response.status}): ${errorMessage}`);
|
|
995
|
+
}
|
|
996
|
+
return response.json();
|
|
997
|
+
}
|
|
998
|
+
// ==================== App State Management ====================
|
|
999
|
+
/**
|
|
1000
|
+
* List all apps for the authenticated user
|
|
1001
|
+
*/
|
|
1002
|
+
async listApps() {
|
|
1003
|
+
const { apps } = await this.request("GET", "/api/apps");
|
|
1004
|
+
return apps;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Get a specific app by name
|
|
1008
|
+
*/
|
|
1009
|
+
async getApp(appName) {
|
|
1010
|
+
try {
|
|
1011
|
+
const { app } = await this.request("GET", `/api/apps/${appName}`);
|
|
1012
|
+
return app;
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
throw error;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Create or update an app
|
|
1022
|
+
* Returns the app with auto-generated domainName
|
|
1023
|
+
*/
|
|
1024
|
+
async saveApp(cluster) {
|
|
1025
|
+
const { app } = await this.request("POST", "/api/apps", {
|
|
1026
|
+
appName: cluster.appName,
|
|
1027
|
+
location: cluster.location,
|
|
1028
|
+
uncloudContext: cluster.uncloudContext,
|
|
1029
|
+
nodes: cluster.nodes.map((node) => ({
|
|
1030
|
+
name: node.name,
|
|
1031
|
+
id: node.id,
|
|
1032
|
+
ipv4: node.ipv4,
|
|
1033
|
+
ipv6: node.ipv6,
|
|
1034
|
+
isPrimary: node.isPrimary
|
|
1035
|
+
}))
|
|
1036
|
+
});
|
|
1037
|
+
return app;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Update app's last deployed timestamp
|
|
1041
|
+
*/
|
|
1042
|
+
async updateLastDeployed(appName) {
|
|
1043
|
+
await this.request("PATCH", `/api/apps/${appName}`, {
|
|
1044
|
+
lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Rename an app (per-user unique)
|
|
1049
|
+
*/
|
|
1050
|
+
async renameApp(currentAppName, newAppName) {
|
|
1051
|
+
const { app } = await this.request("PATCH", `/api/apps/${currentAppName}`, {
|
|
1052
|
+
newAppName
|
|
1053
|
+
});
|
|
1054
|
+
return app;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Rename app's domain (globally unique)
|
|
1058
|
+
*/
|
|
1059
|
+
async renameDomain(appName, newDomainName) {
|
|
1060
|
+
const { app } = await this.request("PATCH", `/api/apps/${appName}`, {
|
|
1061
|
+
newDomainName
|
|
1062
|
+
});
|
|
1063
|
+
return app;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Delete an app
|
|
1067
|
+
*/
|
|
1068
|
+
async deleteApp(appName) {
|
|
1069
|
+
await this.request("DELETE", `/api/apps/${appName}`);
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Check if app exists
|
|
1073
|
+
*/
|
|
1074
|
+
async hasApp(appName) {
|
|
1075
|
+
const app = await this.getApp(appName);
|
|
1076
|
+
return app !== null;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Get primary node for an app
|
|
1080
|
+
*/
|
|
1081
|
+
async getPrimaryNode(appName) {
|
|
1082
|
+
const app = await this.getApp(appName);
|
|
1083
|
+
return app?.nodes.find((node) => node.isPrimary) || null;
|
|
1084
|
+
}
|
|
1085
|
+
// ==================== VM Operations (Proxied to Ubicloud) ====================
|
|
1086
|
+
/**
|
|
1087
|
+
* List all VMs for the user's Ubicloud project
|
|
1088
|
+
*/
|
|
1089
|
+
async listVMs() {
|
|
1090
|
+
const { vms } = await this.request("GET", "/api/vms");
|
|
1091
|
+
return vms;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Create a new VM
|
|
1095
|
+
*/
|
|
1096
|
+
async createVM(params) {
|
|
1097
|
+
const { vm } = await this.request("POST", "/api/vms", params);
|
|
1098
|
+
return vm;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Get a specific VM
|
|
1102
|
+
*/
|
|
1103
|
+
async getVM(location, vmName) {
|
|
1104
|
+
const { vm } = await this.request(
|
|
1105
|
+
"GET",
|
|
1106
|
+
`/api/vms/${location}/${vmName}`
|
|
1107
|
+
);
|
|
1108
|
+
return vm;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Delete a VM
|
|
1112
|
+
*/
|
|
1113
|
+
async deleteVM(location, vmName) {
|
|
1114
|
+
await this.request("DELETE", `/api/vms/${location}/${vmName}`);
|
|
1115
|
+
}
|
|
1116
|
+
// ==================== Uncloud Config Management ====================
|
|
1117
|
+
/**
|
|
1118
|
+
* Upload uncloud config to platform
|
|
1119
|
+
*/
|
|
1120
|
+
async uploadUncloudConfig(configYaml) {
|
|
1121
|
+
await this.request("POST", "/api/uncloud/config", { config: configYaml });
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Download uncloud config from platform
|
|
1125
|
+
*/
|
|
1126
|
+
async downloadUncloudConfig() {
|
|
1127
|
+
try {
|
|
1128
|
+
const { config } = await this.request("GET", "/api/uncloud/config");
|
|
1129
|
+
return config;
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
throw error;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// ==================== Gateway Management ====================
|
|
1138
|
+
/**
|
|
1139
|
+
* Get gateway info for a location
|
|
1140
|
+
*/
|
|
1141
|
+
async getGateway(location) {
|
|
1142
|
+
try {
|
|
1143
|
+
const { gateway } = await this.request("GET", `/api/gateway?location=${encodeURIComponent(location)}`);
|
|
1144
|
+
return gateway;
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
// ==================== Route Management ====================
|
|
1153
|
+
/**
|
|
1154
|
+
* Register a route for an app (maps hostname to VM IPv6)
|
|
1155
|
+
*/
|
|
1156
|
+
async registerRoute(appName, backendIpv6, backendPort = 443) {
|
|
1157
|
+
const { route } = await this.request("POST", "/api/routes", {
|
|
1158
|
+
appName,
|
|
1159
|
+
backendIpv6,
|
|
1160
|
+
backendPort
|
|
1161
|
+
});
|
|
1162
|
+
return route;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Delete routes for an app
|
|
1166
|
+
*/
|
|
1167
|
+
async deleteRoute(appName) {
|
|
1168
|
+
await this.request("DELETE", `/api/routes/${encodeURIComponent(appName)}`);
|
|
1169
|
+
}
|
|
1170
|
+
// ==================== Gateway Route Sync ====================
|
|
1171
|
+
/**
|
|
1172
|
+
* Sync gateway Caddy configuration for a location
|
|
1173
|
+
* This updates the gateway's reverse proxy config with current routes
|
|
1174
|
+
*/
|
|
1175
|
+
async syncGatewayRoutes(location) {
|
|
1176
|
+
const result = await this.request("POST", "/api/gateway/sync", { location });
|
|
1177
|
+
return { routeCount: result.routeCount };
|
|
1178
|
+
}
|
|
1179
|
+
// ==================== SSH Certificate Management ====================
|
|
1180
|
+
/**
|
|
1181
|
+
* Get platform SSH keys (CA public key + platform public key for VM creation)
|
|
1182
|
+
*/
|
|
1183
|
+
async getPlatformSSHKeys() {
|
|
1184
|
+
return this.request("GET", "/api/platform/ssh-keys");
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Initialize platform SSH keys (creates them if they don't exist)
|
|
1188
|
+
*/
|
|
1189
|
+
async initPlatformSSHKeys() {
|
|
1190
|
+
return this.request("POST", "/api/platform/ssh-keys");
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Request a signed SSH certificate for accessing an app's VMs
|
|
1194
|
+
* Returns a short-lived certificate (5 min) signed by the platform CA
|
|
1195
|
+
*/
|
|
1196
|
+
async requestSSHCertificate(appName, publicKey) {
|
|
1197
|
+
return this.request("POST", `/api/apps/${appName}/ssh-certificate`, { publicKey });
|
|
1198
|
+
}
|
|
1199
|
+
// ==================== VM Setup ====================
|
|
1200
|
+
/**
|
|
1201
|
+
* Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
|
|
1202
|
+
* This runs all configuration using the platform SSH key
|
|
1203
|
+
*/
|
|
1204
|
+
async setupVM(vmIp, location, appName) {
|
|
1205
|
+
await this.request("POST", "/api/vms/setup", {
|
|
1206
|
+
vmIp,
|
|
1207
|
+
location,
|
|
1208
|
+
appName
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
// ==================== Environment Variables ====================
|
|
1212
|
+
/**
|
|
1213
|
+
* Set a single environment variable
|
|
1214
|
+
*/
|
|
1215
|
+
async setEnvVar(appName, key, value) {
|
|
1216
|
+
await this.request("POST", `/api/apps/${appName}/env`, { key, value });
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Set multiple environment variables at once
|
|
1220
|
+
*/
|
|
1221
|
+
async setEnvVars(appName, vars) {
|
|
1222
|
+
await this.request("POST", `/api/apps/${appName}/env`, { vars });
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* List environment variables (values are masked)
|
|
1226
|
+
*/
|
|
1227
|
+
async listEnvVars(appName) {
|
|
1228
|
+
const { vars } = await this.request("GET", `/api/apps/${appName}/env`);
|
|
1229
|
+
return vars;
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Remove an environment variable
|
|
1233
|
+
*/
|
|
1234
|
+
async unsetEnvVar(appName, key) {
|
|
1235
|
+
await this.request("DELETE", `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
|
|
1236
|
+
}
|
|
1237
|
+
// ==================== GitHub Connection ====================
|
|
1238
|
+
/**
|
|
1239
|
+
* Create app metadata without creating VM (for connect-before-deploy flow)
|
|
1240
|
+
*/
|
|
1241
|
+
async createAppMetadata(appName, location) {
|
|
1242
|
+
const { app } = await this.request("POST", "/api/apps", {
|
|
1243
|
+
appName,
|
|
1244
|
+
location: location || "eu-central-h1",
|
|
1245
|
+
metadataOnly: true
|
|
1246
|
+
// Signal that we don't want to create VM yet
|
|
1247
|
+
});
|
|
1248
|
+
return app;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Initiate GitHub App installation flow
|
|
1252
|
+
*/
|
|
1253
|
+
async initiateGitHubConnect() {
|
|
1254
|
+
return this.request("POST", "/api/github/connect");
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Poll for GitHub App installation completion
|
|
1258
|
+
*/
|
|
1259
|
+
async pollGitHubConnect(stateToken) {
|
|
1260
|
+
return this.request("GET", `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Get current user's GitHub App installation
|
|
1264
|
+
*/
|
|
1265
|
+
async getGitHubInstallation() {
|
|
1266
|
+
try {
|
|
1267
|
+
const { installation } = await this.request("GET", "/api/github/installation");
|
|
1268
|
+
return installation;
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
throw error;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* List repositories accessible via GitHub App installation
|
|
1278
|
+
*/
|
|
1279
|
+
async listAccessibleRepos() {
|
|
1280
|
+
const { repos } = await this.request("GET", "/api/github/repos");
|
|
1281
|
+
return repos;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Connect a repository to an app
|
|
1285
|
+
*/
|
|
1286
|
+
async connectRepo(appName, repoFullName, branch) {
|
|
1287
|
+
await this.request("POST", `/api/apps/${appName}/repo`, {
|
|
1288
|
+
repoFullName,
|
|
1289
|
+
branch: branch || "main"
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Get connected repository for an app
|
|
1294
|
+
*/
|
|
1295
|
+
async getConnectedRepo(appName) {
|
|
1296
|
+
try {
|
|
1297
|
+
const { repo } = await this.request("GET", `/api/apps/${appName}/repo`);
|
|
1298
|
+
return repo;
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
if (error.message.includes("404") || error.message.includes("not found")) {
|
|
1301
|
+
return null;
|
|
1302
|
+
}
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Disconnect repository from an app
|
|
1308
|
+
*/
|
|
1309
|
+
async disconnectRepo(appName) {
|
|
1310
|
+
await this.request("DELETE", `/api/apps/${appName}/repo`);
|
|
1311
|
+
}
|
|
1312
|
+
// ==================== Builds ====================
|
|
1313
|
+
/**
|
|
1314
|
+
* List builds for an app
|
|
1315
|
+
*/
|
|
1316
|
+
async listBuilds(appName, limit = 10) {
|
|
1317
|
+
const { builds } = await this.request("GET", `/api/apps/${appName}/builds?limit=${limit}`);
|
|
1318
|
+
return builds;
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Get build details
|
|
1322
|
+
*/
|
|
1323
|
+
async getBuild(appName, buildId) {
|
|
1324
|
+
const { build } = await this.request("GET", `/api/apps/${appName}/builds/${buildId}`);
|
|
1325
|
+
return build;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Get build events (for live streaming)
|
|
1329
|
+
*/
|
|
1330
|
+
async getBuildEvents(appName, buildId, after) {
|
|
1331
|
+
let url = `/api/apps/${appName}/builds/${buildId}/events`;
|
|
1332
|
+
if (after) {
|
|
1333
|
+
url += `?after=${encodeURIComponent(after.toISOString())}`;
|
|
1334
|
+
}
|
|
1335
|
+
const { events } = await this.request("GET", url);
|
|
1336
|
+
return events;
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// src/lib/app-config.ts
|
|
1341
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1342
|
+
import { join as join3 } from "path";
|
|
1343
|
+
import { parse, stringify } from "yaml";
|
|
1344
|
+
var CONFIG_FILENAME = "hackerrun.yaml";
|
|
1345
|
+
function getConfigPath(directory = process.cwd()) {
|
|
1346
|
+
return join3(directory, CONFIG_FILENAME);
|
|
1347
|
+
}
|
|
1348
|
+
function hasAppConfig(directory = process.cwd()) {
|
|
1349
|
+
return existsSync3(getConfigPath(directory));
|
|
1350
|
+
}
|
|
1351
|
+
function readAppConfig(directory = process.cwd()) {
|
|
1352
|
+
const configPath = getConfigPath(directory);
|
|
1353
|
+
if (!existsSync3(configPath)) {
|
|
1354
|
+
return null;
|
|
1355
|
+
}
|
|
1356
|
+
try {
|
|
1357
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
1358
|
+
const config = parse(content);
|
|
1359
|
+
if (!config.appName) {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
return config;
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
console.error(`Failed to parse ${CONFIG_FILENAME}:`, error);
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
function writeAppConfig(config, directory = process.cwd()) {
|
|
1369
|
+
const configPath = getConfigPath(directory);
|
|
1370
|
+
const content = stringify(config);
|
|
1371
|
+
writeFileSync3(configPath, content, "utf-8");
|
|
1372
|
+
}
|
|
1373
|
+
function getAppName(directory = process.cwd()) {
|
|
1374
|
+
const config = readAppConfig(directory);
|
|
1375
|
+
if (config?.appName) {
|
|
1376
|
+
return config.appName;
|
|
1377
|
+
}
|
|
1378
|
+
const folderName = directory.split("/").pop() || "app";
|
|
1379
|
+
return folderName.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
1380
|
+
}
|
|
1381
|
+
function linkApp(appName, directory = process.cwd()) {
|
|
1382
|
+
writeAppConfig({ appName }, directory);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// src/lib/uncloud-runner.ts
|
|
1386
|
+
import { execSync as execSync4, spawnSync as spawnSync2, spawn } from "child_process";
|
|
1387
|
+
import * as net2 from "net";
|
|
1388
|
+
import chalk6 from "chalk";
|
|
1389
|
+
var UncloudRunner = class {
|
|
1390
|
+
constructor(platformClient) {
|
|
1391
|
+
this.platformClient = platformClient;
|
|
1392
|
+
this.certManager = new SSHCertManager(platformClient);
|
|
1393
|
+
}
|
|
1394
|
+
certManager;
|
|
1395
|
+
gatewayCache = /* @__PURE__ */ new Map();
|
|
1396
|
+
activeTunnels = /* @__PURE__ */ new Map();
|
|
1397
|
+
tempConfigPath = null;
|
|
1398
|
+
/**
|
|
1399
|
+
* Get the connection URL for an app's primary VM
|
|
1400
|
+
* Handles IPv6 direct connection or gateway proxy fallback
|
|
1401
|
+
*/
|
|
1402
|
+
async getConnectionInfo(appName) {
|
|
1403
|
+
const app = await this.platformClient.getApp(appName);
|
|
1404
|
+
if (!app) {
|
|
1405
|
+
throw new Error(`App '${appName}' not found`);
|
|
1406
|
+
}
|
|
1407
|
+
const primaryNode = app.nodes.find((n) => n.isPrimary);
|
|
1408
|
+
if (!primaryNode?.ipv6) {
|
|
1409
|
+
throw new Error(`App '${appName}' has no primary node with IPv6 address`);
|
|
1410
|
+
}
|
|
1411
|
+
const vmIp = primaryNode.ipv6;
|
|
1412
|
+
await this.certManager.getSession(appName, vmIp);
|
|
1413
|
+
const canConnectDirect = await testIPv6Connectivity(vmIp, 3e3);
|
|
1414
|
+
const viaGateway = !canConnectDirect;
|
|
1415
|
+
if (viaGateway) {
|
|
1416
|
+
const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
|
|
1417
|
+
return {
|
|
1418
|
+
url: `ssh://root@localhost:${tunnelInfo.localPort}`,
|
|
1419
|
+
vmIp,
|
|
1420
|
+
viaGateway: true,
|
|
1421
|
+
localPort: tunnelInfo.localPort
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
url: `ssh://root@${vmIp}`,
|
|
1426
|
+
vmIp,
|
|
1427
|
+
viaGateway: false
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Pre-accept a host's SSH key by running ssh-keyscan
|
|
1432
|
+
* This prevents "Host key verification failed" errors from uncloud
|
|
1433
|
+
*/
|
|
1434
|
+
preAcceptHostKey(host, port) {
|
|
1435
|
+
try {
|
|
1436
|
+
const portArg = port ? `-p ${port}` : "";
|
|
1437
|
+
execSync4(
|
|
1438
|
+
`ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
|
|
1439
|
+
{ stdio: "pipe", timeout: 1e4 }
|
|
1440
|
+
);
|
|
1441
|
+
} catch {
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Ensure an SSH tunnel exists for gateway fallback
|
|
1446
|
+
*/
|
|
1447
|
+
async ensureTunnel(appName, vmIp, location) {
|
|
1448
|
+
const existing = this.activeTunnels.get(appName);
|
|
1449
|
+
if (existing && !existing.process.killed) {
|
|
1450
|
+
return existing;
|
|
1451
|
+
}
|
|
1452
|
+
const gateway = await this.getGateway(location);
|
|
1453
|
+
if (!gateway) {
|
|
1454
|
+
throw new Error(`No gateway found for location ${location}`);
|
|
1455
|
+
}
|
|
1456
|
+
const localPort = await this.findAvailablePort();
|
|
1457
|
+
const tunnelProcess = spawn("ssh", [
|
|
1458
|
+
"-N",
|
|
1459
|
+
// No remote command
|
|
1460
|
+
"-L",
|
|
1461
|
+
`${localPort}:[${vmIp}]:22`,
|
|
1462
|
+
// Local port forward
|
|
1463
|
+
"-o",
|
|
1464
|
+
"StrictHostKeyChecking=no",
|
|
1465
|
+
"-o",
|
|
1466
|
+
"UserKnownHostsFile=/dev/null",
|
|
1467
|
+
"-o",
|
|
1468
|
+
"LogLevel=ERROR",
|
|
1469
|
+
"-o",
|
|
1470
|
+
"ExitOnForwardFailure=yes",
|
|
1471
|
+
"-o",
|
|
1472
|
+
"ServerAliveInterval=30",
|
|
1473
|
+
`root@${gateway.ipv4}`
|
|
1474
|
+
], {
|
|
1475
|
+
detached: true,
|
|
1476
|
+
stdio: "pipe"
|
|
1477
|
+
});
|
|
1478
|
+
await this.waitForTunnel(localPort);
|
|
1479
|
+
const tunnelInfo = { process: tunnelProcess, localPort };
|
|
1480
|
+
this.activeTunnels.set(appName, tunnelInfo);
|
|
1481
|
+
return tunnelInfo;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Find an available local port
|
|
1485
|
+
*/
|
|
1486
|
+
async findAvailablePort() {
|
|
1487
|
+
return new Promise((resolve, reject) => {
|
|
1488
|
+
const server = net2.createServer();
|
|
1489
|
+
server.listen(0, () => {
|
|
1490
|
+
const port = server.address().port;
|
|
1491
|
+
server.close(() => resolve(port));
|
|
1492
|
+
});
|
|
1493
|
+
server.on("error", reject);
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Wait for tunnel to be established
|
|
1498
|
+
*/
|
|
1499
|
+
async waitForTunnel(port, timeoutMs = 1e4) {
|
|
1500
|
+
const startTime = Date.now();
|
|
1501
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1502
|
+
try {
|
|
1503
|
+
await new Promise((resolve, reject) => {
|
|
1504
|
+
const socket = net2.createConnection(port, "localhost", () => {
|
|
1505
|
+
socket.destroy();
|
|
1506
|
+
resolve();
|
|
1507
|
+
});
|
|
1508
|
+
socket.on("error", () => {
|
|
1509
|
+
socket.destroy();
|
|
1510
|
+
reject();
|
|
1511
|
+
});
|
|
1512
|
+
socket.setTimeout(500, () => {
|
|
1513
|
+
socket.destroy();
|
|
1514
|
+
reject();
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1517
|
+
return;
|
|
1518
|
+
} catch {
|
|
1519
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
throw new Error(`Tunnel failed to establish on port ${port}`);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Run an uncloud command on the app's VM
|
|
1526
|
+
*/
|
|
1527
|
+
async run(appName, command, args = [], options = {}) {
|
|
1528
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1529
|
+
if (connInfo.viaGateway) {
|
|
1530
|
+
console.log(chalk6.dim(`Connecting via gateway...`));
|
|
1531
|
+
}
|
|
1532
|
+
const fullArgs = ["--connect", connInfo.url, command, ...args];
|
|
1533
|
+
if (options.stdio === "inherit") {
|
|
1534
|
+
const result = spawnSync2("uc", fullArgs, {
|
|
1535
|
+
cwd: options.cwd,
|
|
1536
|
+
stdio: "inherit",
|
|
1537
|
+
timeout: options.timeout
|
|
1538
|
+
});
|
|
1539
|
+
if (result.status !== 0) {
|
|
1540
|
+
throw new Error(`Uncloud command failed with exit code ${result.status}`);
|
|
1541
|
+
}
|
|
1542
|
+
} else {
|
|
1543
|
+
const result = execSync4(`uc ${fullArgs.map((a) => `"${a}"`).join(" ")}`, {
|
|
1544
|
+
cwd: options.cwd,
|
|
1545
|
+
encoding: "utf-8",
|
|
1546
|
+
timeout: options.timeout
|
|
1547
|
+
});
|
|
1548
|
+
return result;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Run 'uc deploy' for an app
|
|
1553
|
+
*/
|
|
1554
|
+
async deploy(appName, cwd) {
|
|
1555
|
+
await this.run(appName, "deploy", ["--yes"], { cwd, stdio: "inherit" });
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Run 'uc service logs' for an app
|
|
1559
|
+
*/
|
|
1560
|
+
async logs(appName, serviceName, options = {}) {
|
|
1561
|
+
const args = [serviceName];
|
|
1562
|
+
if (options.follow) args.push("-f");
|
|
1563
|
+
if (options.tail) args.push("--tail", String(options.tail));
|
|
1564
|
+
await this.run(appName, "service", ["logs", ...args], { stdio: "inherit" });
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Run 'uc service ls' for an app
|
|
1568
|
+
*/
|
|
1569
|
+
async serviceList(appName) {
|
|
1570
|
+
const result = await this.run(appName, "service", ["ls"], { stdio: "pipe" });
|
|
1571
|
+
return result;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Run 'uc machine token' on remote VM via SSH
|
|
1575
|
+
* This is different - it runs on the VM directly, not via uncloud connector
|
|
1576
|
+
*/
|
|
1577
|
+
async getMachineToken(appName) {
|
|
1578
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1579
|
+
let sshCmd2;
|
|
1580
|
+
if (connInfo.viaGateway && connInfo.localPort) {
|
|
1581
|
+
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
1582
|
+
} else {
|
|
1583
|
+
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
1584
|
+
}
|
|
1585
|
+
const token = execSync4(`${sshCmd2} "uc machine token"`, { encoding: "utf-8" }).trim();
|
|
1586
|
+
return token;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Execute an SSH command on the VM
|
|
1590
|
+
*/
|
|
1591
|
+
async sshExec(appName, command) {
|
|
1592
|
+
const connInfo = await this.getConnectionInfo(appName);
|
|
1593
|
+
let sshCmd2;
|
|
1594
|
+
if (connInfo.viaGateway && connInfo.localPort) {
|
|
1595
|
+
sshCmd2 = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
|
|
1596
|
+
} else {
|
|
1597
|
+
sshCmd2 = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
|
|
1598
|
+
}
|
|
1599
|
+
return execSync4(`${sshCmd2} "${command}"`, { encoding: "utf-8" }).trim();
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Get gateway info (cached)
|
|
1603
|
+
*/
|
|
1604
|
+
async getGateway(location) {
|
|
1605
|
+
if (this.gatewayCache.has(location)) {
|
|
1606
|
+
return this.gatewayCache.get(location) || null;
|
|
1607
|
+
}
|
|
1608
|
+
const gateway = await this.platformClient.getGateway(location);
|
|
1609
|
+
if (gateway) {
|
|
1610
|
+
this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
|
|
1611
|
+
return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
|
|
1612
|
+
}
|
|
1613
|
+
this.gatewayCache.set(location, null);
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Clean up all SSH sessions and tunnels
|
|
1618
|
+
*/
|
|
1619
|
+
cleanup() {
|
|
1620
|
+
for (const [appName, tunnel] of this.activeTunnels) {
|
|
1621
|
+
try {
|
|
1622
|
+
tunnel.process.kill();
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
this.activeTunnels.clear();
|
|
1627
|
+
this.certManager.cleanupAll();
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
// src/commands/deploy.ts
|
|
1632
|
+
var DEFAULT_LOCATION = "eu-central-h1";
|
|
1633
|
+
var DEFAULT_SIZE = "burstable-1";
|
|
1634
|
+
var DEFAULT_STORAGE_SIZE = 10;
|
|
1635
|
+
var DEFAULT_IMAGE = "ubuntu-noble";
|
|
1636
|
+
function createDeployCommand() {
|
|
1637
|
+
const cmd = new Command2("deploy");
|
|
1638
|
+
cmd.description("Deploy your application to hackerrun").option("-n, --name <name>", "App name (defaults to hackerrun.yaml or directory name)").option("--app <app>", "App name (alias for --name, for CI/CD compatibility)").option("-l, --location <location>", "VM location").option("-s, --size <size>", "VM size (default: burstable-1)").option("--storage <gb>", "Storage size in GB (default: 10)").option("-i, --image <image>", "Boot image").option("--build-token <token>", "Build token for CI/CD (bypasses normal auth)").action(async (options) => {
|
|
1639
|
+
try {
|
|
1640
|
+
PlatformDetector.ensureSupported();
|
|
1641
|
+
await UncloudManager.ensureInstalled();
|
|
1642
|
+
let platformToken;
|
|
1643
|
+
const isCIBuild = !!options.buildToken;
|
|
1644
|
+
if (isCIBuild) {
|
|
1645
|
+
platformToken = options.buildToken;
|
|
1646
|
+
} else {
|
|
1647
|
+
platformToken = getPlatformToken();
|
|
1648
|
+
}
|
|
1649
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1650
|
+
const clusterManager = new ClusterManager(platformClient);
|
|
1651
|
+
const uncloudRunner = new UncloudRunner(platformClient);
|
|
1652
|
+
const appName = options.app || options.name || getAppName();
|
|
1653
|
+
const location = options.location || DEFAULT_LOCATION;
|
|
1654
|
+
const vmSize = options.size || DEFAULT_SIZE;
|
|
1655
|
+
const storageSize = options.storage ? parseInt(options.storage) : DEFAULT_STORAGE_SIZE;
|
|
1656
|
+
const bootImage = options.image || DEFAULT_IMAGE;
|
|
1657
|
+
console.log(chalk7.cyan(`
|
|
1658
|
+
Deploying '${appName}' to hackerrun...
|
|
1659
|
+
`));
|
|
1660
|
+
let cluster = await platformClient.getApp(appName);
|
|
1661
|
+
let isFirstDeploy = false;
|
|
1662
|
+
if (!cluster) {
|
|
1663
|
+
isFirstDeploy = true;
|
|
1664
|
+
console.log(chalk7.yellow("First deployment - creating infrastructure...\n"));
|
|
1665
|
+
cluster = await clusterManager.initializeCluster({
|
|
1666
|
+
appName,
|
|
1667
|
+
location,
|
|
1668
|
+
vmSize,
|
|
1669
|
+
storageSize,
|
|
1670
|
+
bootImage
|
|
1671
|
+
});
|
|
1672
|
+
console.log(chalk7.cyan("\nWhat just happened:"));
|
|
1673
|
+
console.log(` ${chalk7.green("\u2713")} Created 1 IPv6-only VM (${vmSize})`);
|
|
1674
|
+
console.log(` ${chalk7.green("\u2713")} Installed Docker and Uncloud daemon`);
|
|
1675
|
+
console.log(` ${chalk7.green("\u2713")} Initialized uncloud cluster`);
|
|
1676
|
+
console.log(` ${chalk7.green("\u2713")} Assigned domain: ${cluster.domainName}.hackerrun.app`);
|
|
1677
|
+
console.log();
|
|
1678
|
+
if (!hasAppConfig()) {
|
|
1679
|
+
linkApp(appName);
|
|
1680
|
+
console.log(chalk7.dim(`Created hackerrun.yaml for app linking
|
|
1681
|
+
`));
|
|
1682
|
+
}
|
|
1683
|
+
} else {
|
|
1684
|
+
console.log(chalk7.green(`Using existing infrastructure (${cluster.nodes.length} VM(s))
|
|
1685
|
+
`));
|
|
1686
|
+
}
|
|
1687
|
+
const primaryNode = await platformClient.getPrimaryNode(appName);
|
|
1688
|
+
if (!primaryNode || !primaryNode.ipv6) {
|
|
1689
|
+
throw new Error("Primary node not found or has no IPv6 address");
|
|
1690
|
+
}
|
|
1691
|
+
console.log(chalk7.cyan("\nRunning deployment...\n"));
|
|
1692
|
+
try {
|
|
1693
|
+
await uncloudRunner.deploy(appName, process.cwd());
|
|
1694
|
+
console.log(chalk7.green("\nApp deployed successfully!"));
|
|
1695
|
+
if (cluster.domainName) {
|
|
1696
|
+
const spinner = ora4("Registering route...").start();
|
|
1697
|
+
try {
|
|
1698
|
+
const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
|
|
1699
|
+
spinner.succeed(chalk7.green(`Route registered: ${route.fullUrl}`));
|
|
1700
|
+
spinner.start("Syncing gateway routes...");
|
|
1701
|
+
await platformClient.syncGatewayRoutes(cluster.location);
|
|
1702
|
+
spinner.succeed(chalk7.green("Gateway routes synced"));
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
spinner.warn(chalk7.yellow(`Could not register route: ${error.message}`));
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
await platformClient.updateLastDeployed(appName);
|
|
1708
|
+
console.log(chalk7.cyan("\nDeployment Summary:\n"));
|
|
1709
|
+
console.log(` App Name: ${chalk7.bold(appName)}`);
|
|
1710
|
+
console.log(` Domain: ${chalk7.bold(`${cluster.domainName}.hackerrun.app`)}`);
|
|
1711
|
+
console.log(` URL: ${chalk7.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
|
|
1712
|
+
console.log(` Location: ${cluster.location}`);
|
|
1713
|
+
console.log(` Nodes: ${cluster.nodes.length}`);
|
|
1714
|
+
console.log(chalk7.cyan("\nInfrastructure:\n"));
|
|
1715
|
+
cluster.nodes.forEach((node, index) => {
|
|
1716
|
+
const prefix = node.isPrimary ? chalk7.yellow("(primary)") : " ";
|
|
1717
|
+
const ip = node.ipv6 || node.ipv4 || "pending";
|
|
1718
|
+
console.log(` ${prefix} ${node.name} - ${ip}`);
|
|
1719
|
+
});
|
|
1720
|
+
console.log(chalk7.cyan("\nNext steps:\n"));
|
|
1721
|
+
console.log(` View logs: ${chalk7.bold(`hackerrun logs ${appName}`)}`);
|
|
1722
|
+
console.log(` SSH access: ${chalk7.bold(`hackerrun ssh ${appName}`)}`);
|
|
1723
|
+
console.log(` Change domain: ${chalk7.bold(`hackerrun domain --app ${appName} <new-name>`)}`);
|
|
1724
|
+
console.log();
|
|
1725
|
+
uncloudRunner.cleanup();
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
uncloudRunner.cleanup();
|
|
1728
|
+
console.log(chalk7.red("\nDeployment failed"));
|
|
1729
|
+
throw error;
|
|
1730
|
+
}
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
console.error(chalk7.red(`
|
|
1733
|
+
Error: ${error.message}
|
|
1734
|
+
`));
|
|
1735
|
+
process.exit(1);
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
return cmd;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// src/commands/app.ts
|
|
1742
|
+
import { Command as Command3 } from "commander";
|
|
1743
|
+
import chalk8 from "chalk";
|
|
1744
|
+
import ora5 from "ora";
|
|
1745
|
+
import { execSync as execSync5 } from "child_process";
|
|
1746
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "fs";
|
|
1747
|
+
import { homedir as homedir2 } from "os";
|
|
1748
|
+
import { join as join4 } from "path";
|
|
1749
|
+
import YAML from "yaml";
|
|
1750
|
+
function removeUncloudContext(contextName) {
|
|
1751
|
+
const configPath = join4(homedir2(), ".config", "uncloud", "config.yaml");
|
|
1752
|
+
if (!existsSync4(configPath)) {
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
const content = readFileSync4(configPath, "utf-8");
|
|
1757
|
+
const config = YAML.parse(content);
|
|
1758
|
+
if (!config.contexts || !config.contexts[contextName]) {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
delete config.contexts[contextName];
|
|
1762
|
+
if (config.current_context === contextName) {
|
|
1763
|
+
const remainingContexts = Object.keys(config.contexts);
|
|
1764
|
+
config.current_context = remainingContexts.length > 0 ? remainingContexts[0] : "";
|
|
1765
|
+
}
|
|
1766
|
+
writeFileSync4(configPath, YAML.stringify(config));
|
|
1767
|
+
return true;
|
|
1768
|
+
} catch {
|
|
1769
|
+
return false;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
function createAppCommands() {
|
|
1773
|
+
const appsCmd2 = new Command3("apps");
|
|
1774
|
+
appsCmd2.description("List all your apps and their infrastructure").action(async () => {
|
|
1775
|
+
try {
|
|
1776
|
+
const platformToken = getPlatformToken();
|
|
1777
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1778
|
+
const apps = await platformClient.listApps();
|
|
1779
|
+
if (apps.length === 0) {
|
|
1780
|
+
console.log(chalk8.yellow("\nNo apps deployed yet.\n"));
|
|
1781
|
+
console.log(`Run ${chalk8.bold("hackerrun deploy")} to deploy your first app!
|
|
1782
|
+
`);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
console.log(chalk8.cyan("\n Your Apps:\n"));
|
|
1786
|
+
apps.forEach((app) => {
|
|
1787
|
+
console.log(chalk8.bold(` ${app.appName}`));
|
|
1788
|
+
console.log(` Domain: ${app.domainName}.hackerrun.app`);
|
|
1789
|
+
console.log(` URL: https://${app.domainName}.hackerrun.app`);
|
|
1790
|
+
console.log(` Location: ${app.location}`);
|
|
1791
|
+
console.log(` Nodes: ${app.nodes.length}`);
|
|
1792
|
+
console.log(` Created: ${new Date(app.createdAt).toLocaleString()}`);
|
|
1793
|
+
if (app.lastDeployedAt) {
|
|
1794
|
+
console.log(` Last Deploy: ${new Date(app.lastDeployedAt).toLocaleString()}`);
|
|
1795
|
+
}
|
|
1796
|
+
const primaryNode = app.nodes.find((n) => n.isPrimary);
|
|
1797
|
+
console.log(` Primary Node: ${primaryNode?.ipv6 || primaryNode?.ipv4 || "N/A"}`);
|
|
1798
|
+
console.log();
|
|
1799
|
+
});
|
|
1800
|
+
console.log(chalk8.green(`Total: ${apps.length} app(s)
|
|
1801
|
+
`));
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
console.error(chalk8.red(`
|
|
1804
|
+
Error: ${error.message}
|
|
1805
|
+
`));
|
|
1806
|
+
process.exit(1);
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
const nodesCmd2 = new Command3("nodes");
|
|
1810
|
+
nodesCmd2.description("List all VMs in an app's cluster").argument("<app>", "App name").action(async (appName) => {
|
|
1811
|
+
try {
|
|
1812
|
+
const platformToken = getPlatformToken();
|
|
1813
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1814
|
+
const app = await platformClient.getApp(appName);
|
|
1815
|
+
if (!app) {
|
|
1816
|
+
console.log(chalk8.red(`
|
|
1817
|
+
App '${appName}' not found
|
|
1818
|
+
`));
|
|
1819
|
+
process.exit(1);
|
|
1820
|
+
}
|
|
1821
|
+
console.log(chalk8.cyan(`
|
|
1822
|
+
Nodes in '${appName}':
|
|
1823
|
+
`));
|
|
1824
|
+
app.nodes.forEach((node, index) => {
|
|
1825
|
+
const primaryLabel = node.isPrimary ? chalk8.yellow(" (primary)") : "";
|
|
1826
|
+
console.log(` ${index + 1}. ${chalk8.bold(node.name)}${primaryLabel}`);
|
|
1827
|
+
console.log(` IPv6: ${node.ipv6 || "pending"}`);
|
|
1828
|
+
if (node.ipv4) {
|
|
1829
|
+
console.log(` IPv4: ${node.ipv4}`);
|
|
1830
|
+
}
|
|
1831
|
+
console.log(` ID: ${node.id}`);
|
|
1832
|
+
console.log();
|
|
1833
|
+
});
|
|
1834
|
+
console.log(chalk8.green(`Total: ${app.nodes.length} node(s)
|
|
1835
|
+
`));
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
console.error(chalk8.red(`
|
|
1838
|
+
Error: ${error.message}
|
|
1839
|
+
`));
|
|
1840
|
+
process.exit(1);
|
|
1841
|
+
}
|
|
1842
|
+
});
|
|
1843
|
+
const sshCmd2 = new Command3("ssh");
|
|
1844
|
+
sshCmd2.description("SSH into an app's VM").argument("<app>", "App name").option("-n, --node <node>", "Node name or index (defaults to primary node)").action(async (appName, options) => {
|
|
1845
|
+
const platformToken = getPlatformToken();
|
|
1846
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1847
|
+
const uncloudRunner = new UncloudRunner(platformClient);
|
|
1848
|
+
try {
|
|
1849
|
+
const app = await platformClient.getApp(appName);
|
|
1850
|
+
if (!app) {
|
|
1851
|
+
console.log(chalk8.red(`
|
|
1852
|
+
App '${appName}' not found
|
|
1853
|
+
`));
|
|
1854
|
+
process.exit(1);
|
|
1855
|
+
}
|
|
1856
|
+
let targetNode;
|
|
1857
|
+
if (options.node) {
|
|
1858
|
+
const nodeIndex = parseInt(options.node);
|
|
1859
|
+
if (!isNaN(nodeIndex) && nodeIndex > 0 && nodeIndex <= app.nodes.length) {
|
|
1860
|
+
targetNode = app.nodes[nodeIndex - 1];
|
|
1861
|
+
} else {
|
|
1862
|
+
targetNode = app.nodes.find((n) => n.name === options.node);
|
|
1863
|
+
}
|
|
1864
|
+
if (!targetNode) {
|
|
1865
|
+
console.log(chalk8.red(`
|
|
1866
|
+
Node '${options.node}' not found
|
|
1867
|
+
`));
|
|
1868
|
+
process.exit(1);
|
|
1869
|
+
}
|
|
1870
|
+
} else {
|
|
1871
|
+
targetNode = app.nodes.find((n) => n.isPrimary);
|
|
1872
|
+
}
|
|
1873
|
+
const nodeIp = targetNode?.ipv6 || targetNode?.ipv4;
|
|
1874
|
+
if (!targetNode || !nodeIp) {
|
|
1875
|
+
console.log(chalk8.red(`
|
|
1876
|
+
Target node not found or has no IP
|
|
1877
|
+
`));
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
const connInfo = await uncloudRunner.getConnectionInfo(appName);
|
|
1881
|
+
if (connInfo.viaGateway) {
|
|
1882
|
+
console.log(chalk8.cyan(`
|
|
1883
|
+
Connecting to ${targetNode.name} via gateway...
|
|
1884
|
+
`));
|
|
1885
|
+
} else {
|
|
1886
|
+
console.log(chalk8.cyan(`
|
|
1887
|
+
Connecting to ${targetNode.name}...
|
|
1888
|
+
`));
|
|
1889
|
+
}
|
|
1890
|
+
if (connInfo.viaGateway && connInfo.localPort) {
|
|
1891
|
+
execSync5(
|
|
1892
|
+
`ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`,
|
|
1893
|
+
{ stdio: "inherit" }
|
|
1894
|
+
);
|
|
1895
|
+
} else {
|
|
1896
|
+
execSync5(
|
|
1897
|
+
`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${nodeIp}`,
|
|
1898
|
+
{ stdio: "inherit" }
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
} catch (error) {
|
|
1902
|
+
console.error(chalk8.red(`
|
|
1903
|
+
Error: ${error.message}
|
|
1904
|
+
`));
|
|
1905
|
+
process.exit(1);
|
|
1906
|
+
} finally {
|
|
1907
|
+
uncloudRunner.cleanup();
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
const destroyCmd2 = new Command3("destroy");
|
|
1911
|
+
destroyCmd2.description("Delete an app and all its infrastructure").argument("<app>", "App name").option("--force", "Skip confirmation").action(async (appName, options) => {
|
|
1912
|
+
try {
|
|
1913
|
+
const platformToken = getPlatformToken();
|
|
1914
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1915
|
+
const app = await platformClient.getApp(appName);
|
|
1916
|
+
if (!app) {
|
|
1917
|
+
console.log(chalk8.red(`
|
|
1918
|
+
\u274C App '${appName}' not found
|
|
1919
|
+
`));
|
|
1920
|
+
process.exit(1);
|
|
1921
|
+
}
|
|
1922
|
+
if (!options.force) {
|
|
1923
|
+
console.log(chalk8.yellow(`
|
|
1924
|
+
\u26A0\uFE0F You are about to delete '${appName}' and all its infrastructure:`));
|
|
1925
|
+
console.log(` - ${app.nodes.length} VM(s) will be deleted`);
|
|
1926
|
+
console.log(` - All data will be lost
|
|
1927
|
+
`);
|
|
1928
|
+
console.log(chalk8.red("Use --force flag to confirm deletion\n"));
|
|
1929
|
+
process.exit(1);
|
|
1930
|
+
}
|
|
1931
|
+
const spinner = ora5("Deleting infrastructure...").start();
|
|
1932
|
+
try {
|
|
1933
|
+
for (const node of app.nodes) {
|
|
1934
|
+
spinner.text = `Deleting VM '${node.name}'...`;
|
|
1935
|
+
await platformClient.deleteVM(app.location, node.name);
|
|
1936
|
+
}
|
|
1937
|
+
spinner.text = "Removing app from database...";
|
|
1938
|
+
await platformClient.deleteApp(appName);
|
|
1939
|
+
spinner.text = "Cleaning up local configuration...";
|
|
1940
|
+
const contextName = app.uncloudContext || appName;
|
|
1941
|
+
removeUncloudContext(contextName);
|
|
1942
|
+
spinner.succeed(chalk8.green(`App '${appName}' destroyed successfully`));
|
|
1943
|
+
console.log(chalk8.cyan("\n\u{1F4A1} All infrastructure has been deleted.\n"));
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
spinner.fail(chalk8.red("Failed to destroy app"));
|
|
1946
|
+
throw error;
|
|
1947
|
+
}
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
console.error(chalk8.red(`
|
|
1950
|
+
\u274C Error: ${error.message}
|
|
1951
|
+
`));
|
|
1952
|
+
process.exit(1);
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
const linkCmd2 = new Command3("link");
|
|
1956
|
+
linkCmd2.description("Link current directory to an existing app").argument("<app-name>", "App name to link to").action(async (appName) => {
|
|
1957
|
+
try {
|
|
1958
|
+
const platformToken = getPlatformToken();
|
|
1959
|
+
const platformClient = new PlatformClient(platformToken);
|
|
1960
|
+
const app = await platformClient.getApp(appName);
|
|
1961
|
+
if (!app) {
|
|
1962
|
+
console.log(chalk8.red(`
|
|
1963
|
+
App '${appName}' not found
|
|
1964
|
+
`));
|
|
1965
|
+
console.log(`Run ${chalk8.bold("hackerrun apps")} to see your apps.
|
|
1966
|
+
`);
|
|
1967
|
+
process.exit(1);
|
|
1968
|
+
}
|
|
1969
|
+
const existingConfig = readAppConfig();
|
|
1970
|
+
if (existingConfig) {
|
|
1971
|
+
if (existingConfig.appName === appName) {
|
|
1972
|
+
console.log(chalk8.yellow(`
|
|
1973
|
+
This directory is already linked to '${appName}'
|
|
1974
|
+
`));
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
console.log(chalk8.yellow(`
|
|
1978
|
+
This directory is currently linked to '${existingConfig.appName}'`));
|
|
1979
|
+
console.log(`Updating link to '${appName}'...
|
|
1980
|
+
`);
|
|
1981
|
+
}
|
|
1982
|
+
linkApp(appName);
|
|
1983
|
+
console.log(chalk8.green(`
|
|
1984
|
+
Linked to app '${appName}'
|
|
1985
|
+
`));
|
|
1986
|
+
console.log(` Domain: https://${app.domainName}.hackerrun.app`);
|
|
1987
|
+
console.log(` Run ${chalk8.bold("hackerrun deploy")} to deploy changes.
|
|
1988
|
+
`);
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
console.error(chalk8.red(`
|
|
1991
|
+
Error: ${error.message}
|
|
1992
|
+
`));
|
|
1993
|
+
process.exit(1);
|
|
1994
|
+
}
|
|
1995
|
+
});
|
|
1996
|
+
const renameCmd2 = new Command3("rename");
|
|
1997
|
+
renameCmd2.description("Rename an app (per-user unique)").requiredOption("--app <current-name>", "Current app name").argument("<new-name>", "New app name").action(async (newName, options) => {
|
|
1998
|
+
try {
|
|
1999
|
+
const platformToken = getPlatformToken();
|
|
2000
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2001
|
+
const currentName = options.app;
|
|
2002
|
+
const app = await platformClient.getApp(currentName);
|
|
2003
|
+
if (!app) {
|
|
2004
|
+
console.log(chalk8.red(`
|
|
2005
|
+
App '${currentName}' not found
|
|
2006
|
+
`));
|
|
2007
|
+
process.exit(1);
|
|
2008
|
+
}
|
|
2009
|
+
const spinner = ora5(`Renaming app '${currentName}' to '${newName}'...`).start();
|
|
2010
|
+
try {
|
|
2011
|
+
const updatedApp = await platformClient.renameApp(currentName, newName);
|
|
2012
|
+
spinner.succeed(chalk8.green(`App renamed successfully`));
|
|
2013
|
+
console.log(`
|
|
2014
|
+
Old name: ${currentName}`);
|
|
2015
|
+
console.log(` New name: ${updatedApp.appName}`);
|
|
2016
|
+
console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app
|
|
2017
|
+
`);
|
|
2018
|
+
const existingConfig = readAppConfig();
|
|
2019
|
+
if (existingConfig?.appName === currentName) {
|
|
2020
|
+
linkApp(newName);
|
|
2021
|
+
console.log(chalk8.dim(`Updated local hackerrun.yaml
|
|
2022
|
+
`));
|
|
2023
|
+
}
|
|
2024
|
+
} catch (error) {
|
|
2025
|
+
spinner.fail(chalk8.red("Failed to rename app"));
|
|
2026
|
+
throw error;
|
|
2027
|
+
}
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
console.error(chalk8.red(`
|
|
2030
|
+
Error: ${error.message}
|
|
2031
|
+
`));
|
|
2032
|
+
process.exit(1);
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
const domainCmd2 = new Command3("domain");
|
|
2036
|
+
domainCmd2.description("Change app's domain name (globally unique)").requiredOption("--app <app-name>", "App name").argument("<new-domain>", "New domain name (without .hackerrun.app)").action(async (newDomain, options) => {
|
|
2037
|
+
try {
|
|
2038
|
+
const platformToken = getPlatformToken();
|
|
2039
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2040
|
+
const appName = options.app;
|
|
2041
|
+
const app = await platformClient.getApp(appName);
|
|
2042
|
+
if (!app) {
|
|
2043
|
+
console.log(chalk8.red(`
|
|
2044
|
+
App '${appName}' not found
|
|
2045
|
+
`));
|
|
2046
|
+
process.exit(1);
|
|
2047
|
+
}
|
|
2048
|
+
const oldDomain = app.domainName;
|
|
2049
|
+
const spinner = ora5(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
|
|
2050
|
+
try {
|
|
2051
|
+
const updatedApp = await platformClient.renameDomain(appName, newDomain);
|
|
2052
|
+
spinner.succeed(chalk8.green(`Domain changed successfully`));
|
|
2053
|
+
spinner.start("Syncing gateway routes...");
|
|
2054
|
+
await platformClient.syncGatewayRoutes(app.location);
|
|
2055
|
+
spinner.succeed(chalk8.green("Gateway routes synced"));
|
|
2056
|
+
console.log(`
|
|
2057
|
+
App: ${updatedApp.appName}`);
|
|
2058
|
+
console.log(` Old domain: https://${oldDomain}.hackerrun.app`);
|
|
2059
|
+
console.log(` New domain: https://${updatedApp.domainName}.hackerrun.app
|
|
2060
|
+
`);
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
spinner.fail(chalk8.red("Failed to change domain"));
|
|
2063
|
+
throw error;
|
|
2064
|
+
}
|
|
2065
|
+
} catch (error) {
|
|
2066
|
+
console.error(chalk8.red(`
|
|
2067
|
+
Error: ${error.message}
|
|
2068
|
+
`));
|
|
2069
|
+
process.exit(1);
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
return { appsCmd: appsCmd2, nodesCmd: nodesCmd2, sshCmd: sshCmd2, destroyCmd: destroyCmd2, linkCmd: linkCmd2, renameCmd: renameCmd2, domainCmd: domainCmd2 };
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/commands/config.ts
|
|
2076
|
+
import { Command as Command4 } from "commander";
|
|
2077
|
+
import chalk9 from "chalk";
|
|
2078
|
+
function createConfigCommand() {
|
|
2079
|
+
const cmd = new Command4("config");
|
|
2080
|
+
cmd.description("Manage hackerrun configuration");
|
|
2081
|
+
cmd.command("set").description("Set a configuration value").argument("<key>", "Configuration key (apiToken)").argument("<value>", "Configuration value").action((key, value) => {
|
|
2082
|
+
try {
|
|
2083
|
+
const configManager = new ConfigManager();
|
|
2084
|
+
if (key !== "apiToken") {
|
|
2085
|
+
console.log(chalk9.red(`
|
|
2086
|
+
\u274C Invalid key: ${key}
|
|
2087
|
+
`));
|
|
2088
|
+
console.log(chalk9.cyan("Valid keys:"));
|
|
2089
|
+
console.log(" - apiToken Platform API token\n");
|
|
2090
|
+
process.exit(1);
|
|
2091
|
+
}
|
|
2092
|
+
configManager.set(key, value);
|
|
2093
|
+
const displayValue = configManager.getAll(true).apiToken;
|
|
2094
|
+
console.log(chalk9.green(`
|
|
2095
|
+
\u2713 Set ${key} = ${displayValue}
|
|
2096
|
+
`));
|
|
2097
|
+
} catch (error) {
|
|
2098
|
+
console.error(chalk9.red(`
|
|
2099
|
+
\u274C Error: ${error.message}
|
|
2100
|
+
`));
|
|
2101
|
+
process.exit(1);
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
cmd.command("get").description("Get a configuration value").argument("<key>", "Configuration key").option("--show-secrets", "Show unmasked sensitive values").action((key, options) => {
|
|
2105
|
+
try {
|
|
2106
|
+
const configManager = new ConfigManager();
|
|
2107
|
+
if (key !== "apiToken") {
|
|
2108
|
+
console.log(chalk9.red(`
|
|
2109
|
+
\u274C Invalid key: ${key}
|
|
2110
|
+
`));
|
|
2111
|
+
process.exit(1);
|
|
2112
|
+
}
|
|
2113
|
+
const value = configManager.get(key);
|
|
2114
|
+
if (!value) {
|
|
2115
|
+
console.log(chalk9.yellow(`
|
|
2116
|
+
\u26A0\uFE0F ${key} is not set
|
|
2117
|
+
`));
|
|
2118
|
+
process.exit(1);
|
|
2119
|
+
}
|
|
2120
|
+
const displayValue = options.showSecrets ? value : configManager.getAll(true).apiToken;
|
|
2121
|
+
console.log(chalk9.cyan(`
|
|
2122
|
+
${key} = ${displayValue}
|
|
2123
|
+
`));
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
console.error(chalk9.red(`
|
|
2126
|
+
\u274C Error: ${error.message}
|
|
2127
|
+
`));
|
|
2128
|
+
process.exit(1);
|
|
2129
|
+
}
|
|
2130
|
+
});
|
|
2131
|
+
cmd.command("list").description("List all configuration values").option("--show-secrets", "Show unmasked sensitive values").action((options) => {
|
|
2132
|
+
try {
|
|
2133
|
+
const configManager = new ConfigManager();
|
|
2134
|
+
const config = configManager.getAll(!options.showSecrets);
|
|
2135
|
+
if (Object.keys(config).length === 0) {
|
|
2136
|
+
console.log(chalk9.yellow("\n\u26A0\uFE0F No configuration found\n"));
|
|
2137
|
+
console.log(chalk9.cyan("Get started by logging in:\n"));
|
|
2138
|
+
console.log(" hackerrun login\n");
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
console.log(chalk9.cyan("\n\u{1F4DD} Configuration:\n"));
|
|
2142
|
+
console.log(` apiToken: ${config.apiToken || chalk9.red("not set")}`);
|
|
2143
|
+
console.log(chalk9.dim(`
|
|
2144
|
+
Config file: ${configManager.getConfigPath()}
|
|
2145
|
+
`));
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
console.error(chalk9.red(`
|
|
2148
|
+
\u274C Error: ${error.message}
|
|
2149
|
+
`));
|
|
2150
|
+
process.exit(1);
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
cmd.command("unset").description("Remove a configuration value").argument("<key>", "Configuration key").action((key) => {
|
|
2154
|
+
try {
|
|
2155
|
+
const configManager = new ConfigManager();
|
|
2156
|
+
if (key !== "apiToken") {
|
|
2157
|
+
console.log(chalk9.red(`
|
|
2158
|
+
\u274C Invalid key: ${key}
|
|
2159
|
+
`));
|
|
2160
|
+
process.exit(1);
|
|
2161
|
+
}
|
|
2162
|
+
configManager.unset(key);
|
|
2163
|
+
console.log(chalk9.green(`
|
|
2164
|
+
\u2713 Removed ${key}
|
|
2165
|
+
`));
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
console.error(chalk9.red(`
|
|
2168
|
+
\u274C Error: ${error.message}
|
|
2169
|
+
`));
|
|
2170
|
+
process.exit(1);
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
cmd.command("path").description("Show the config file path").action(() => {
|
|
2174
|
+
try {
|
|
2175
|
+
const configManager = new ConfigManager();
|
|
2176
|
+
console.log(configManager.getConfigPath());
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
console.error(chalk9.red(`
|
|
2179
|
+
\u274C Error: ${error.message}
|
|
2180
|
+
`));
|
|
2181
|
+
process.exit(1);
|
|
2182
|
+
}
|
|
2183
|
+
});
|
|
2184
|
+
return cmd;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// src/commands/logs.ts
|
|
2188
|
+
import { Command as Command5 } from "commander";
|
|
2189
|
+
import chalk10 from "chalk";
|
|
2190
|
+
function createLogsCommand() {
|
|
2191
|
+
const cmd = new Command5("logs");
|
|
2192
|
+
cmd.description("View logs for app services").argument("<app>", "App name").argument("[service]", "Service name (optional - lists services if omitted)").option("-f, --follow", "Follow log output").option("--tail <lines>", "Number of lines to show from the end of the logs").action(async (appName, serviceName, options) => {
|
|
2193
|
+
const platformToken = getPlatformToken();
|
|
2194
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2195
|
+
const uncloudRunner = new UncloudRunner(platformClient);
|
|
2196
|
+
try {
|
|
2197
|
+
const app = await platformClient.getApp(appName);
|
|
2198
|
+
if (!app) {
|
|
2199
|
+
console.log(chalk10.red(`
|
|
2200
|
+
App '${appName}' not found
|
|
2201
|
+
`));
|
|
2202
|
+
console.log(chalk10.cyan("Available apps:"));
|
|
2203
|
+
const apps = await platformClient.listApps();
|
|
2204
|
+
if (apps.length === 0) {
|
|
2205
|
+
console.log(chalk10.yellow(" No apps deployed yet\n"));
|
|
2206
|
+
} else {
|
|
2207
|
+
apps.forEach((a) => console.log(` - ${a.appName}`));
|
|
2208
|
+
console.log();
|
|
2209
|
+
}
|
|
2210
|
+
process.exit(1);
|
|
2211
|
+
}
|
|
2212
|
+
if (!serviceName) {
|
|
2213
|
+
try {
|
|
2214
|
+
const output = await uncloudRunner.serviceList(appName);
|
|
2215
|
+
const lines = output.trim().split("\n");
|
|
2216
|
+
const services = [];
|
|
2217
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2218
|
+
const line = lines[i].trim();
|
|
2219
|
+
if (!line || line.startsWith("NAME") || line.startsWith("Connecting") || line.startsWith("Connected")) {
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
const svcName = line.split(/\s+/)[0];
|
|
2223
|
+
if (svcName) {
|
|
2224
|
+
services.push(svcName);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
if (services.length === 0) {
|
|
2228
|
+
console.log(chalk10.yellow(`
|
|
2229
|
+
No services found in app '${appName}'
|
|
2230
|
+
`));
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
console.log(chalk10.cyan(`
|
|
2234
|
+
Available services in '${appName}':
|
|
2235
|
+
`));
|
|
2236
|
+
services.forEach((s) => console.log(` ${chalk10.bold(s)}`));
|
|
2237
|
+
console.log(chalk10.dim(`
|
|
2238
|
+
Usage: hackerrun logs ${appName} <service>`));
|
|
2239
|
+
console.log(chalk10.dim(`Example: hackerrun logs ${appName} ${services[0]}
|
|
2240
|
+
`));
|
|
2241
|
+
return;
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
console.error(chalk10.red("\n Failed to list services"));
|
|
2244
|
+
console.error(chalk10.yellow("Make sure the app is deployed and running\n"));
|
|
2245
|
+
process.exit(1);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
console.log(chalk10.cyan(`
|
|
2249
|
+
Viewing logs for '${serviceName}' in app '${appName}'...
|
|
2250
|
+
`));
|
|
2251
|
+
try {
|
|
2252
|
+
await uncloudRunner.logs(appName, serviceName, {
|
|
2253
|
+
follow: options.follow,
|
|
2254
|
+
tail: options.tail ? parseInt(options.tail) : void 0
|
|
2255
|
+
});
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
console.error(chalk10.red(`
|
|
2258
|
+
Failed to fetch logs for service '${serviceName}'`));
|
|
2259
|
+
console.error(chalk10.yellow("The service may not exist or may not be running\n"));
|
|
2260
|
+
process.exit(1);
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
console.error(chalk10.red(`
|
|
2264
|
+
Error: ${error.message}
|
|
2265
|
+
`));
|
|
2266
|
+
process.exit(1);
|
|
2267
|
+
} finally {
|
|
2268
|
+
uncloudRunner.cleanup();
|
|
2269
|
+
}
|
|
2270
|
+
});
|
|
2271
|
+
return cmd;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// src/commands/env.ts
|
|
2275
|
+
import { Command as Command6 } from "commander";
|
|
2276
|
+
import chalk11 from "chalk";
|
|
2277
|
+
import ora6 from "ora";
|
|
2278
|
+
import { password } from "@inquirer/prompts";
|
|
2279
|
+
import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
|
|
2280
|
+
function parseEnvFile(content) {
|
|
2281
|
+
const vars = {};
|
|
2282
|
+
for (const line of content.split("\n")) {
|
|
2283
|
+
const trimmed = line.trim();
|
|
2284
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
2285
|
+
const eqIndex = trimmed.indexOf("=");
|
|
2286
|
+
if (eqIndex === -1) continue;
|
|
2287
|
+
const key = trimmed.slice(0, eqIndex);
|
|
2288
|
+
let value = trimmed.slice(eqIndex + 1);
|
|
2289
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2290
|
+
value = value.slice(1, -1);
|
|
2291
|
+
}
|
|
2292
|
+
vars[key] = value;
|
|
2293
|
+
}
|
|
2294
|
+
return vars;
|
|
2295
|
+
}
|
|
2296
|
+
function createEnvCommand() {
|
|
2297
|
+
const cmd = new Command6("env");
|
|
2298
|
+
cmd.description("Manage environment variables for an app");
|
|
2299
|
+
cmd.command("set").description("Set an environment variable").argument("<keyValue>", "KEY=value or just KEY (will prompt for value)").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").action(async (keyValue, options) => {
|
|
2300
|
+
try {
|
|
2301
|
+
const appName = options.app || getAppName();
|
|
2302
|
+
const platformToken = getPlatformToken();
|
|
2303
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2304
|
+
let key;
|
|
2305
|
+
let value;
|
|
2306
|
+
if (keyValue.includes("=")) {
|
|
2307
|
+
const parts = keyValue.split("=");
|
|
2308
|
+
key = parts[0];
|
|
2309
|
+
value = parts.slice(1).join("=");
|
|
2310
|
+
} else {
|
|
2311
|
+
key = keyValue;
|
|
2312
|
+
value = await password({
|
|
2313
|
+
message: `Enter value for ${key}:`,
|
|
2314
|
+
mask: "*"
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
if (!key) {
|
|
2318
|
+
console.error(chalk11.red("\nError: Key cannot be empty\n"));
|
|
2319
|
+
process.exit(1);
|
|
2320
|
+
}
|
|
2321
|
+
const spinner = ora6(`Setting ${key}...`).start();
|
|
2322
|
+
await platformClient.setEnvVar(appName, key, value);
|
|
2323
|
+
spinner.succeed(`Set ${key}`);
|
|
2324
|
+
} catch (error) {
|
|
2325
|
+
console.error(chalk11.red(`
|
|
2326
|
+
Error: ${error.message}
|
|
2327
|
+
`));
|
|
2328
|
+
process.exit(1);
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
cmd.command("upload").description("Upload environment variables from a file").argument("<file>", "Path to .env file").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").action(async (filePath, options) => {
|
|
2332
|
+
try {
|
|
2333
|
+
const appName = options.app || getAppName();
|
|
2334
|
+
const platformToken = getPlatformToken();
|
|
2335
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2336
|
+
if (!existsSync5(filePath)) {
|
|
2337
|
+
console.error(chalk11.red(`
|
|
2338
|
+
Error: File not found: ${filePath}
|
|
2339
|
+
`));
|
|
2340
|
+
process.exit(1);
|
|
2341
|
+
}
|
|
2342
|
+
const content = readFileSync5(filePath, "utf-8");
|
|
2343
|
+
const vars = parseEnvFile(content);
|
|
2344
|
+
const count = Object.keys(vars).length;
|
|
2345
|
+
if (count === 0) {
|
|
2346
|
+
console.log(chalk11.yellow("\nNo environment variables found in file.\n"));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const spinner = ora6(`Uploading ${count} variable${count > 1 ? "s" : ""}...`).start();
|
|
2350
|
+
await platformClient.setEnvVars(appName, vars);
|
|
2351
|
+
spinner.succeed(`Uploaded ${count} environment variable${count > 1 ? "s" : ""}`);
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
console.error(chalk11.red(`
|
|
2354
|
+
Error: ${error.message}
|
|
2355
|
+
`));
|
|
2356
|
+
process.exit(1);
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
cmd.command("list").description("List environment variables").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").action(async (options) => {
|
|
2360
|
+
try {
|
|
2361
|
+
const appName = options.app || getAppName();
|
|
2362
|
+
const platformToken = getPlatformToken();
|
|
2363
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2364
|
+
const spinner = ora6("Fetching environment variables...").start();
|
|
2365
|
+
const vars = await platformClient.listEnvVars(appName);
|
|
2366
|
+
spinner.stop();
|
|
2367
|
+
if (vars.length === 0) {
|
|
2368
|
+
console.log(chalk11.yellow(`
|
|
2369
|
+
No environment variables set for '${appName}'.
|
|
2370
|
+
`));
|
|
2371
|
+
console.log(chalk11.cyan("Set variables with:\n"));
|
|
2372
|
+
console.log(` hackerrun env set KEY=value`);
|
|
2373
|
+
console.log(` hackerrun env upload .env
|
|
2374
|
+
`);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
console.log(chalk11.cyan(`
|
|
2378
|
+
Environment variables for '${appName}':
|
|
2379
|
+
`));
|
|
2380
|
+
for (const v of vars) {
|
|
2381
|
+
const masked = "*".repeat(Math.min(v.valueLength, 20));
|
|
2382
|
+
console.log(` ${chalk11.bold(v.key)}=${chalk11.dim(masked)}`);
|
|
2383
|
+
}
|
|
2384
|
+
console.log();
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
console.error(chalk11.red(`
|
|
2387
|
+
Error: ${error.message}
|
|
2388
|
+
`));
|
|
2389
|
+
process.exit(1);
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
cmd.command("unset").description("Remove an environment variable").argument("<key>", "Environment variable key").option("--app <app>", "App name (uses hackerrun.yaml if not specified)").action(async (key, options) => {
|
|
2393
|
+
try {
|
|
2394
|
+
const appName = options.app || getAppName();
|
|
2395
|
+
const platformToken = getPlatformToken();
|
|
2396
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2397
|
+
const spinner = ora6(`Removing ${key}...`).start();
|
|
2398
|
+
await platformClient.unsetEnvVar(appName, key);
|
|
2399
|
+
spinner.succeed(`Removed ${key}`);
|
|
2400
|
+
} catch (error) {
|
|
2401
|
+
console.error(chalk11.red(`
|
|
2402
|
+
Error: ${error.message}
|
|
2403
|
+
`));
|
|
2404
|
+
process.exit(1);
|
|
2405
|
+
}
|
|
2406
|
+
});
|
|
2407
|
+
return cmd;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// src/commands/connect.ts
|
|
2411
|
+
import { Command as Command7 } from "commander";
|
|
2412
|
+
import chalk12 from "chalk";
|
|
2413
|
+
import ora7 from "ora";
|
|
2414
|
+
import { select } from "@inquirer/prompts";
|
|
2415
|
+
function sleep2(ms) {
|
|
2416
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2417
|
+
}
|
|
2418
|
+
async function pollForInstallation(platformClient, stateToken, spinner, maxAttempts = 60, intervalMs = 5e3) {
|
|
2419
|
+
let attempts = 0;
|
|
2420
|
+
while (attempts < maxAttempts) {
|
|
2421
|
+
await sleep2(intervalMs);
|
|
2422
|
+
attempts++;
|
|
2423
|
+
const result = await platformClient.pollGitHubConnect(stateToken);
|
|
2424
|
+
if (result.status === "complete" && result.installationId) {
|
|
2425
|
+
return { installationId: result.installationId };
|
|
2426
|
+
} else if (result.status === "expired") {
|
|
2427
|
+
throw new Error("Authorization expired. Please try again.");
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
throw new Error("Authorization timed out. Please try again.");
|
|
2431
|
+
}
|
|
2432
|
+
function createConnectCommand() {
|
|
2433
|
+
const cmd = new Command7("connect");
|
|
2434
|
+
cmd.description("Connect a GitHub repository for automatic deploys").option("--app <app>", "App name (uses hackerrun.yaml or folder name if not specified)").action(async (options) => {
|
|
2435
|
+
try {
|
|
2436
|
+
const appName = options.app || getAppName();
|
|
2437
|
+
const platformToken = getPlatformToken();
|
|
2438
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2439
|
+
console.log(chalk12.cyan(`
|
|
2440
|
+
Connecting GitHub repository to '${appName}'
|
|
2441
|
+
`));
|
|
2442
|
+
let app = await platformClient.getApp(appName);
|
|
2443
|
+
if (!app) {
|
|
2444
|
+
const spinner = ora7("Creating app...").start();
|
|
2445
|
+
app = await platformClient.createAppMetadata(appName);
|
|
2446
|
+
spinner.succeed(`Created app '${appName}'`);
|
|
2447
|
+
if (!hasAppConfig()) {
|
|
2448
|
+
linkApp(appName);
|
|
2449
|
+
console.log(chalk12.dim(` Created hackerrun.yaml`));
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
const existingRepo = await platformClient.getConnectedRepo(appName);
|
|
2453
|
+
if (existingRepo) {
|
|
2454
|
+
console.log(chalk12.yellow(`
|
|
2455
|
+
App '${appName}' is already connected to:`));
|
|
2456
|
+
console.log(` ${chalk12.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})
|
|
2457
|
+
`);
|
|
2458
|
+
const action = await select({
|
|
2459
|
+
message: "What would you like to do?",
|
|
2460
|
+
choices: [
|
|
2461
|
+
{ name: "Change repository", value: "change" },
|
|
2462
|
+
{ name: "Keep current connection", value: "keep" }
|
|
2463
|
+
]
|
|
2464
|
+
});
|
|
2465
|
+
if (action === "keep") {
|
|
2466
|
+
console.log(chalk12.green("\nConnection unchanged.\n"));
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
let installation = await platformClient.getGitHubInstallation();
|
|
2471
|
+
if (!installation) {
|
|
2472
|
+
console.log(chalk12.cyan("First, install the HackerRun GitHub App:\n"));
|
|
2473
|
+
const spinner = ora7("Initiating GitHub connection...").start();
|
|
2474
|
+
const flow = await platformClient.initiateGitHubConnect();
|
|
2475
|
+
spinner.succeed("GitHub connection initiated");
|
|
2476
|
+
console.log(chalk12.cyan("\nPlease complete the following steps:\n"));
|
|
2477
|
+
console.log(` 1. Visit: ${chalk12.bold.blue(flow.authUrl)}`);
|
|
2478
|
+
console.log(` 2. Install the HackerRun app on your account`);
|
|
2479
|
+
console.log(` 3. Select the repositories you want to access
|
|
2480
|
+
`);
|
|
2481
|
+
const pollSpinner = ora7("Waiting for GitHub authorization...").start();
|
|
2482
|
+
try {
|
|
2483
|
+
installation = await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
|
|
2484
|
+
pollSpinner.succeed("GitHub App installed");
|
|
2485
|
+
} catch (error) {
|
|
2486
|
+
pollSpinner.fail(error.message);
|
|
2487
|
+
process.exit(1);
|
|
2488
|
+
}
|
|
2489
|
+
} else {
|
|
2490
|
+
console.log(chalk12.green(`\u2713 GitHub App already installed (${installation.accountLogin})
|
|
2491
|
+
`));
|
|
2492
|
+
}
|
|
2493
|
+
const repoSpinner = ora7("Fetching accessible repositories...").start();
|
|
2494
|
+
const repos = await platformClient.listAccessibleRepos();
|
|
2495
|
+
repoSpinner.stop();
|
|
2496
|
+
if (repos.length === 0) {
|
|
2497
|
+
console.log(chalk12.yellow("\nNo repositories found."));
|
|
2498
|
+
console.log(chalk12.cyan("Make sure you have given HackerRun access to your repositories.\n"));
|
|
2499
|
+
console.log(`Visit ${chalk12.blue("https://github.com/settings/installations")} to manage access.
|
|
2500
|
+
`);
|
|
2501
|
+
process.exit(1);
|
|
2502
|
+
}
|
|
2503
|
+
const selectedRepo = await select({
|
|
2504
|
+
message: "Select repository to connect:",
|
|
2505
|
+
choices: repos.map((r) => ({
|
|
2506
|
+
name: `${r.fullName}${r.private ? chalk12.dim(" (private)") : ""} [${r.defaultBranch}]`,
|
|
2507
|
+
value: r.fullName
|
|
2508
|
+
}))
|
|
2509
|
+
});
|
|
2510
|
+
const selectedRepoInfo = repos.find((r) => r.fullName === selectedRepo);
|
|
2511
|
+
const branch = selectedRepoInfo?.defaultBranch || "main";
|
|
2512
|
+
const connectSpinner = ora7("Connecting repository...").start();
|
|
2513
|
+
await platformClient.connectRepo(appName, selectedRepo, branch);
|
|
2514
|
+
connectSpinner.succeed(`Connected ${selectedRepo}`);
|
|
2515
|
+
console.log(chalk12.green(`
|
|
2516
|
+
\u2713 Successfully connected!
|
|
2517
|
+
`));
|
|
2518
|
+
console.log(chalk12.cyan("Auto-deploy is now enabled:"));
|
|
2519
|
+
console.log(` Repository: ${chalk12.bold(selectedRepo)}`);
|
|
2520
|
+
console.log(` Branch: ${chalk12.bold(branch)}`);
|
|
2521
|
+
console.log(` App: ${chalk12.bold(appName)}
|
|
2522
|
+
`);
|
|
2523
|
+
console.log(chalk12.dim("Every push to this branch will trigger a build and deploy.\n"));
|
|
2524
|
+
} catch (error) {
|
|
2525
|
+
console.error(chalk12.red(`
|
|
2526
|
+
Error: ${error.message}
|
|
2527
|
+
`));
|
|
2528
|
+
process.exit(1);
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
return cmd;
|
|
2532
|
+
}
|
|
2533
|
+
function createDisconnectCommand() {
|
|
2534
|
+
const cmd = new Command7("disconnect");
|
|
2535
|
+
cmd.description("Disconnect GitHub repository from an app").option("--app <app>", "App name (uses hackerrun.yaml or folder name if not specified)").action(async (options) => {
|
|
2536
|
+
try {
|
|
2537
|
+
const appName = options.app || getAppName();
|
|
2538
|
+
const platformToken = getPlatformToken();
|
|
2539
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2540
|
+
const existingRepo = await platformClient.getConnectedRepo(appName);
|
|
2541
|
+
if (!existingRepo) {
|
|
2542
|
+
console.log(chalk12.yellow(`
|
|
2543
|
+
No repository connected to '${appName}'.
|
|
2544
|
+
`));
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
const spinner = ora7("Disconnecting repository...").start();
|
|
2548
|
+
await platformClient.disconnectRepo(appName);
|
|
2549
|
+
spinner.succeed("Repository disconnected");
|
|
2550
|
+
console.log(chalk12.green(`
|
|
2551
|
+
\u2713 Disconnected ${existingRepo.repoFullName} from '${appName}'
|
|
2552
|
+
`));
|
|
2553
|
+
console.log(chalk12.dim("Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n"));
|
|
2554
|
+
} catch (error) {
|
|
2555
|
+
console.error(chalk12.red(`
|
|
2556
|
+
Error: ${error.message}
|
|
2557
|
+
`));
|
|
2558
|
+
process.exit(1);
|
|
2559
|
+
}
|
|
2560
|
+
});
|
|
2561
|
+
return cmd;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// src/commands/builds.ts
|
|
2565
|
+
import { Command as Command8 } from "commander";
|
|
2566
|
+
import chalk13 from "chalk";
|
|
2567
|
+
import ora8 from "ora";
|
|
2568
|
+
function sleep3(ms) {
|
|
2569
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2570
|
+
}
|
|
2571
|
+
function formatDuration(startedAt, completedAt) {
|
|
2572
|
+
if (!startedAt) return "-";
|
|
2573
|
+
const start = new Date(startedAt);
|
|
2574
|
+
const end = completedAt ? new Date(completedAt) : /* @__PURE__ */ new Date();
|
|
2575
|
+
const durationMs = end.getTime() - start.getTime();
|
|
2576
|
+
const seconds = Math.floor(durationMs / 1e3);
|
|
2577
|
+
if (seconds < 60) return `${seconds}s`;
|
|
2578
|
+
const minutes = Math.floor(seconds / 60);
|
|
2579
|
+
const remainingSeconds = seconds % 60;
|
|
2580
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
2581
|
+
}
|
|
2582
|
+
function formatRelativeTime(dateStr) {
|
|
2583
|
+
const date = new Date(dateStr);
|
|
2584
|
+
const now = /* @__PURE__ */ new Date();
|
|
2585
|
+
const diffMs = now.getTime() - date.getTime();
|
|
2586
|
+
const diffSeconds = Math.floor(diffMs / 1e3);
|
|
2587
|
+
if (diffSeconds < 60) return "just now";
|
|
2588
|
+
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)} minutes ago`;
|
|
2589
|
+
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)} hours ago`;
|
|
2590
|
+
return `${Math.floor(diffSeconds / 86400)} days ago`;
|
|
2591
|
+
}
|
|
2592
|
+
function getStatusIcon(status) {
|
|
2593
|
+
switch (status) {
|
|
2594
|
+
case "success":
|
|
2595
|
+
return chalk13.green("\u2713");
|
|
2596
|
+
case "failed":
|
|
2597
|
+
return chalk13.red("\u2717");
|
|
2598
|
+
case "building":
|
|
2599
|
+
return chalk13.yellow("\u25CF");
|
|
2600
|
+
case "pending":
|
|
2601
|
+
return chalk13.dim("\u25CB");
|
|
2602
|
+
default:
|
|
2603
|
+
return chalk13.dim("?");
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
function getStageIcon(stage, status) {
|
|
2607
|
+
if (status === "completed") return chalk13.green("\u2713");
|
|
2608
|
+
if (status === "failed") return chalk13.red("\u2717");
|
|
2609
|
+
if (status === "started") return chalk13.yellow("\u25CF");
|
|
2610
|
+
return chalk13.dim("\u25CB");
|
|
2611
|
+
}
|
|
2612
|
+
function formatEventTime(startTime, eventTime) {
|
|
2613
|
+
const diffMs = eventTime.getTime() - startTime.getTime();
|
|
2614
|
+
const totalSeconds = Math.floor(diffMs / 1e3);
|
|
2615
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2616
|
+
const seconds = totalSeconds % 60;
|
|
2617
|
+
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
2618
|
+
}
|
|
2619
|
+
function createBuildsCommand() {
|
|
2620
|
+
const cmd = new Command8("builds");
|
|
2621
|
+
cmd.description("View and monitor builds").argument("[buildId]", "Build ID to view details").option("--app <app>", "App name (uses hackerrun.yaml or folder name if not specified)").option("--watch", "Watch for new builds").option("--latest", "Stream the latest/current build in real-time").option("-n, --limit <number>", "Number of builds to show", "10").action(async (buildId, options) => {
|
|
2622
|
+
try {
|
|
2623
|
+
const appName = options.app || getAppName();
|
|
2624
|
+
const platformToken = getPlatformToken();
|
|
2625
|
+
const platformClient = new PlatformClient(platformToken);
|
|
2626
|
+
if (buildId) {
|
|
2627
|
+
await showBuildDetails(platformClient, appName, parseInt(buildId, 10));
|
|
2628
|
+
} else if (options.latest) {
|
|
2629
|
+
await streamLatestBuild(platformClient, appName);
|
|
2630
|
+
} else if (options.watch) {
|
|
2631
|
+
await watchBuilds(platformClient, appName);
|
|
2632
|
+
} else {
|
|
2633
|
+
await listBuilds(platformClient, appName, parseInt(options.limit, 10));
|
|
2634
|
+
}
|
|
2635
|
+
} catch (error) {
|
|
2636
|
+
console.error(chalk13.red(`
|
|
2637
|
+
Error: ${error.message}
|
|
2638
|
+
`));
|
|
2639
|
+
process.exit(1);
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
return cmd;
|
|
2643
|
+
}
|
|
2644
|
+
async function listBuilds(platformClient, appName, limit) {
|
|
2645
|
+
const spinner = ora8("Fetching builds...").start();
|
|
2646
|
+
const builds = await platformClient.listBuilds(appName, limit);
|
|
2647
|
+
spinner.stop();
|
|
2648
|
+
if (builds.length === 0) {
|
|
2649
|
+
console.log(chalk13.yellow(`
|
|
2650
|
+
No builds found for '${appName}'.
|
|
2651
|
+
`));
|
|
2652
|
+
console.log(chalk13.cyan("Connect a GitHub repo to enable auto-deploy:\n"));
|
|
2653
|
+
console.log(` hackerrun connect
|
|
2654
|
+
`);
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
console.log(chalk13.cyan(`
|
|
2658
|
+
Builds for '${appName}':
|
|
2659
|
+
`));
|
|
2660
|
+
for (const build of builds) {
|
|
2661
|
+
const icon = getStatusIcon(build.status);
|
|
2662
|
+
const sha = build.commitSha.substring(0, 7);
|
|
2663
|
+
const msg = build.commitMsg ? build.commitMsg.length > 50 ? build.commitMsg.substring(0, 47) + "..." : build.commitMsg : chalk13.dim("No message");
|
|
2664
|
+
const duration = formatDuration(build.startedAt, build.completedAt);
|
|
2665
|
+
const time = formatRelativeTime(build.createdAt);
|
|
2666
|
+
console.log(` ${icon} ${chalk13.bold(`#${build.id}`)} ${chalk13.dim(sha)} ${msg}`);
|
|
2667
|
+
console.log(` ${chalk13.dim(`${build.branch} \u2022 ${duration} \u2022 ${time}`)}`);
|
|
2668
|
+
console.log();
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
async function showBuildDetails(platformClient, appName, buildId) {
|
|
2672
|
+
const spinner = ora8("Fetching build details...").start();
|
|
2673
|
+
const build = await platformClient.getBuild(appName, buildId);
|
|
2674
|
+
const events = await platformClient.getBuildEvents(appName, buildId);
|
|
2675
|
+
spinner.stop();
|
|
2676
|
+
const icon = getStatusIcon(build.status);
|
|
2677
|
+
const sha = build.commitSha.substring(0, 7);
|
|
2678
|
+
console.log(chalk13.cyan(`
|
|
2679
|
+
Build #${build.id}
|
|
2680
|
+
`));
|
|
2681
|
+
console.log(` Status: ${icon} ${build.status}`);
|
|
2682
|
+
console.log(` Commit: ${chalk13.dim(sha)} ${build.commitMsg || chalk13.dim("No message")}`);
|
|
2683
|
+
console.log(` Branch: ${build.branch}`);
|
|
2684
|
+
console.log(` Duration: ${formatDuration(build.startedAt, build.completedAt)}`);
|
|
2685
|
+
console.log(` Created: ${formatRelativeTime(build.createdAt)}`);
|
|
2686
|
+
if (events.length > 0) {
|
|
2687
|
+
console.log(chalk13.cyan("\nBuild Events:\n"));
|
|
2688
|
+
const startTime = new Date(events[0].timestamp);
|
|
2689
|
+
for (const event of events) {
|
|
2690
|
+
const eventTime = new Date(event.timestamp);
|
|
2691
|
+
const timeStr = formatEventTime(startTime, eventTime);
|
|
2692
|
+
const icon2 = getStageIcon(event.stage, event.status);
|
|
2693
|
+
console.log(` [${timeStr}] ${icon2} ${event.message || event.stage}`);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (build.logs) {
|
|
2697
|
+
console.log(chalk13.cyan("\nBuild Logs:\n"));
|
|
2698
|
+
console.log(chalk13.dim(build.logs));
|
|
2699
|
+
}
|
|
2700
|
+
console.log();
|
|
2701
|
+
}
|
|
2702
|
+
async function streamLatestBuild(platformClient, appName) {
|
|
2703
|
+
console.log(chalk13.cyan(`
|
|
2704
|
+
Watching latest build for '${appName}'...
|
|
2705
|
+
`));
|
|
2706
|
+
console.log(chalk13.dim("Press Ctrl+C to stop\n"));
|
|
2707
|
+
let currentBuildId = null;
|
|
2708
|
+
let lastEventTimestamp = null;
|
|
2709
|
+
let buildStartTime = null;
|
|
2710
|
+
process.on("SIGINT", () => {
|
|
2711
|
+
console.log(chalk13.dim("\n\nStopped watching.\n"));
|
|
2712
|
+
process.exit(0);
|
|
2713
|
+
});
|
|
2714
|
+
while (true) {
|
|
2715
|
+
try {
|
|
2716
|
+
const builds = await platformClient.listBuilds(appName, 1);
|
|
2717
|
+
if (builds.length === 0) {
|
|
2718
|
+
console.log(chalk13.dim("Waiting for builds..."));
|
|
2719
|
+
await sleep3(5e3);
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
const latestBuild = builds[0];
|
|
2723
|
+
if (latestBuild.id !== currentBuildId) {
|
|
2724
|
+
currentBuildId = latestBuild.id;
|
|
2725
|
+
lastEventTimestamp = null;
|
|
2726
|
+
buildStartTime = latestBuild.startedAt ? new Date(latestBuild.startedAt) : /* @__PURE__ */ new Date();
|
|
2727
|
+
console.log(chalk13.bold(`
|
|
2728
|
+
Build #${latestBuild.id}`) + ` - commit ${chalk13.dim(latestBuild.commitSha.substring(0, 7))} "${latestBuild.commitMsg || "No message"}"`);
|
|
2729
|
+
console.log(chalk13.dim(`Branch: ${latestBuild.branch} | Started: ${formatRelativeTime(latestBuild.createdAt)}`));
|
|
2730
|
+
console.log();
|
|
2731
|
+
}
|
|
2732
|
+
const events = await platformClient.getBuildEvents(appName, currentBuildId, lastEventTimestamp || void 0);
|
|
2733
|
+
for (const event of events) {
|
|
2734
|
+
const eventTime = new Date(event.timestamp);
|
|
2735
|
+
const timeStr = formatEventTime(buildStartTime, eventTime);
|
|
2736
|
+
const icon = getStageIcon(event.stage, event.status);
|
|
2737
|
+
console.log(`[${timeStr}] ${icon} ${event.message || event.stage}`);
|
|
2738
|
+
lastEventTimestamp = eventTime;
|
|
2739
|
+
}
|
|
2740
|
+
if (latestBuild.status === "success" || latestBuild.status === "failed") {
|
|
2741
|
+
const duration = formatDuration(latestBuild.startedAt, latestBuild.completedAt);
|
|
2742
|
+
const icon = getStatusIcon(latestBuild.status);
|
|
2743
|
+
console.log();
|
|
2744
|
+
console.log(`${icon} Build #${latestBuild.id} ${latestBuild.status} in ${duration}`);
|
|
2745
|
+
console.log(chalk13.dim("\nWaiting for next build...\n"));
|
|
2746
|
+
currentBuildId = null;
|
|
2747
|
+
}
|
|
2748
|
+
await sleep3(2e3);
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
await sleep3(5e3);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
async function watchBuilds(platformClient, appName) {
|
|
2755
|
+
console.log(chalk13.cyan(`
|
|
2756
|
+
Watching builds for '${appName}'...
|
|
2757
|
+
`));
|
|
2758
|
+
console.log(chalk13.dim("Press Ctrl+C to stop\n"));
|
|
2759
|
+
let lastBuildId = null;
|
|
2760
|
+
process.on("SIGINT", () => {
|
|
2761
|
+
console.log(chalk13.dim("\n\nStopped watching.\n"));
|
|
2762
|
+
process.exit(0);
|
|
2763
|
+
});
|
|
2764
|
+
while (true) {
|
|
2765
|
+
try {
|
|
2766
|
+
const builds = await platformClient.listBuilds(appName, 5);
|
|
2767
|
+
if (builds.length === 0) {
|
|
2768
|
+
console.log(chalk13.dim("No builds yet. Waiting..."));
|
|
2769
|
+
await sleep3(5e3);
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2772
|
+
const latestBuild = builds[0];
|
|
2773
|
+
if (latestBuild.id !== lastBuildId) {
|
|
2774
|
+
if (lastBuildId !== null) {
|
|
2775
|
+
const icon = getStatusIcon(latestBuild.status);
|
|
2776
|
+
const sha = latestBuild.commitSha.substring(0, 7);
|
|
2777
|
+
console.log(`${icon} New build #${latestBuild.id} ${chalk13.dim(sha)} "${latestBuild.commitMsg || "No message"}" [${latestBuild.status}]`);
|
|
2778
|
+
}
|
|
2779
|
+
lastBuildId = latestBuild.id;
|
|
2780
|
+
}
|
|
2781
|
+
for (const build of builds) {
|
|
2782
|
+
if (build.status === "building") {
|
|
2783
|
+
const duration = formatDuration(build.startedAt, null);
|
|
2784
|
+
console.log(chalk13.yellow(` \u25CF Build #${build.id} in progress (${duration})...`));
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
await sleep3(5e3);
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
await sleep3(5e3);
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// src/index.ts
|
|
2795
|
+
var program = new Command9();
|
|
2796
|
+
program.name("hackerrun").description("Deploy apps with full control over your infrastructure").version("0.1.0");
|
|
2797
|
+
program.addCommand(createLoginCommand());
|
|
2798
|
+
program.addCommand(createConfigCommand());
|
|
2799
|
+
program.addCommand(createDeployCommand());
|
|
2800
|
+
program.addCommand(createLogsCommand());
|
|
2801
|
+
program.addCommand(createConnectCommand());
|
|
2802
|
+
program.addCommand(createDisconnectCommand());
|
|
2803
|
+
program.addCommand(createEnvCommand());
|
|
2804
|
+
program.addCommand(createBuildsCommand());
|
|
2805
|
+
var { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
|
|
2806
|
+
program.addCommand(appsCmd);
|
|
2807
|
+
program.addCommand(nodesCmd);
|
|
2808
|
+
program.addCommand(sshCmd);
|
|
2809
|
+
program.addCommand(destroyCmd);
|
|
2810
|
+
program.addCommand(linkCmd);
|
|
2811
|
+
program.addCommand(renameCmd);
|
|
2812
|
+
program.addCommand(domainCmd);
|
|
2813
|
+
program.parse();
|