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.
@@ -0,0 +1,637 @@
1
+ // Platform API client - Single source of truth for all app state and VMs
2
+
3
+ export interface VMNode {
4
+ name: string;
5
+ id: string;
6
+ ipv4?: string; // Only for gateway VMs
7
+ ipv6?: string; // All VMs get IPv6
8
+ ip?: string; // Legacy field (deprecated, use ipv6)
9
+ isPrimary: boolean;
10
+ }
11
+
12
+ export interface AppCluster {
13
+ appName: string;
14
+ domainName?: string; // Railway-style domain (e.g., "laughing-buddha")
15
+ location: string;
16
+ nodes: VMNode[];
17
+ uncloudContext: string;
18
+ createdAt: string;
19
+ lastDeployedAt?: string;
20
+ }
21
+
22
+ const PLATFORM_API_URL = process.env.HACKERRUN_API_URL || 'http://localhost:3000';
23
+
24
+ export interface CreateVMParams {
25
+ name: string;
26
+ location: string;
27
+ size: string;
28
+ storage_size?: number; // Storage size in GB (default varies by size)
29
+ unix_user: string;
30
+ public_key: string;
31
+ boot_image: string;
32
+ enable_ip4?: boolean; // Default: false (IPv6-only)
33
+ private_subnet_id?: string; // Put VM in specific private subnet for NAT64 routing
34
+ }
35
+
36
+ export interface UbicloudVM {
37
+ id: string;
38
+ name: string;
39
+ location: string;
40
+ size: string;
41
+ unix_user: string;
42
+ public_key: string;
43
+ boot_image: string;
44
+ state: string;
45
+ status?: string;
46
+ ip4?: string;
47
+ ip6?: string;
48
+ }
49
+
50
+ export class PlatformClient {
51
+ constructor(private authToken: string) {}
52
+
53
+ private async request<T>(
54
+ method: string,
55
+ path: string,
56
+ body?: any
57
+ ): Promise<T> {
58
+ const url = `${PLATFORM_API_URL}${path}`;
59
+ const headers: Record<string, string> = {
60
+ Authorization: `Bearer ${this.authToken}`,
61
+ 'Content-Type': 'application/json',
62
+ // FIX STALE CONNECTION: If retry logic below doesn't reliably fix
63
+ // "SocketError: other side closed" errors, uncomment this line and
64
+ // remove the retry logic in the catch block below.
65
+ // 'Connection': 'close',
66
+ };
67
+
68
+ const doFetch = () => fetch(url, {
69
+ method,
70
+ headers,
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ });
73
+
74
+ let response: Response;
75
+ try {
76
+ response = await doFetch();
77
+ } catch (error) {
78
+ // STALE CONNECTION RETRY: Node.js fetch (undici) reuses TCP connections.
79
+ // After spawnSync operations, connections may become stale (server closed them).
80
+ // Retry once on UND_ERR_SOCKET errors to establish a fresh connection.
81
+ // If this doesn't work reliably, use 'Connection: close' header instead.
82
+ const isSocketError = error instanceof Error &&
83
+ (error.cause as any)?.code === 'UND_ERR_SOCKET';
84
+ if (isSocketError) {
85
+ response = await doFetch();
86
+ } else {
87
+ const errMsg = error instanceof Error ? error.message : 'Unknown error';
88
+ throw new Error(`Failed to connect to platform API (${url}): ${errMsg}`);
89
+ }
90
+ }
91
+
92
+ if (!response.ok) {
93
+ const errorText = await response.text();
94
+ let errorMessage: string;
95
+ try {
96
+ const errorJson = JSON.parse(errorText);
97
+ errorMessage = errorJson.error || response.statusText;
98
+ } catch {
99
+ errorMessage = errorText || response.statusText;
100
+ }
101
+ throw new Error(`API error (${response.status}): ${errorMessage}`);
102
+ }
103
+
104
+ return response.json();
105
+ }
106
+
107
+ // ==================== App State Management ====================
108
+
109
+ /**
110
+ * List all apps for the authenticated user
111
+ */
112
+ async listApps(): Promise<AppCluster[]> {
113
+ const { apps } = await this.request<{ apps: AppCluster[] }>('GET', '/api/apps');
114
+ return apps;
115
+ }
116
+
117
+ /**
118
+ * Get a specific app by name
119
+ */
120
+ async getApp(appName: string): Promise<AppCluster | null> {
121
+ try {
122
+ const { app } = await this.request<{ app: AppCluster }>('GET', `/api/apps/${appName}`);
123
+ return app;
124
+ } catch (error: any) {
125
+ if (error.message.includes('404') || error.message.includes('not found')) {
126
+ return null;
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Create or update an app
134
+ * Returns the app with auto-generated domainName
135
+ */
136
+ async saveApp(cluster: AppCluster): Promise<AppCluster> {
137
+ const { app } = await this.request<{ app: AppCluster }>('POST', '/api/apps', {
138
+ appName: cluster.appName,
139
+ location: cluster.location,
140
+ uncloudContext: cluster.uncloudContext,
141
+ nodes: cluster.nodes.map(node => ({
142
+ name: node.name,
143
+ id: node.id,
144
+ ipv4: node.ipv4,
145
+ ipv6: node.ipv6,
146
+ isPrimary: node.isPrimary,
147
+ })),
148
+ });
149
+ return app;
150
+ }
151
+
152
+ /**
153
+ * Update app's last deployed timestamp
154
+ */
155
+ async updateLastDeployed(appName: string): Promise<void> {
156
+ await this.request('PATCH', `/api/apps/${appName}`, {
157
+ lastDeployedAt: new Date().toISOString(),
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Rename an app (per-user unique)
163
+ */
164
+ async renameApp(currentAppName: string, newAppName: string): Promise<AppCluster> {
165
+ const { app } = await this.request<{ app: AppCluster }>('PATCH', `/api/apps/${currentAppName}`, {
166
+ newAppName,
167
+ });
168
+ return app;
169
+ }
170
+
171
+ /**
172
+ * Rename app's domain (globally unique)
173
+ */
174
+ async renameDomain(appName: string, newDomainName: string): Promise<AppCluster> {
175
+ const { app } = await this.request<{ app: AppCluster }>('PATCH', `/api/apps/${appName}`, {
176
+ newDomainName,
177
+ });
178
+ return app;
179
+ }
180
+
181
+ /**
182
+ * Delete an app
183
+ */
184
+ async deleteApp(appName: string): Promise<void> {
185
+ await this.request('DELETE', `/api/apps/${appName}`);
186
+ }
187
+
188
+ /**
189
+ * Check if app exists
190
+ */
191
+ async hasApp(appName: string): Promise<boolean> {
192
+ const app = await this.getApp(appName);
193
+ return app !== null;
194
+ }
195
+
196
+ /**
197
+ * Get primary node for an app
198
+ */
199
+ async getPrimaryNode(appName: string): Promise<VMNode | null> {
200
+ const app = await this.getApp(appName);
201
+ return app?.nodes.find(node => node.isPrimary) || null;
202
+ }
203
+
204
+ // ==================== VM Operations (Proxied to Ubicloud) ====================
205
+
206
+ /**
207
+ * List all VMs for the user's Ubicloud project
208
+ */
209
+ async listVMs(): Promise<UbicloudVM[]> {
210
+ const { vms } = await this.request<{ vms: UbicloudVM[] }>('GET', '/api/vms');
211
+ return vms;
212
+ }
213
+
214
+ /**
215
+ * Create a new VM
216
+ */
217
+ async createVM(params: CreateVMParams): Promise<UbicloudVM> {
218
+ const { vm } = await this.request<{ vm: UbicloudVM }>('POST', '/api/vms', params);
219
+ return vm;
220
+ }
221
+
222
+ /**
223
+ * Get a specific VM
224
+ */
225
+ async getVM(location: string, vmName: string): Promise<UbicloudVM> {
226
+ const { vm } = await this.request<{ vm: UbicloudVM }>(
227
+ 'GET',
228
+ `/api/vms/${location}/${vmName}`
229
+ );
230
+ return vm;
231
+ }
232
+
233
+ /**
234
+ * Delete a VM
235
+ */
236
+ async deleteVM(location: string, vmName: string): Promise<void> {
237
+ await this.request('DELETE', `/api/vms/${location}/${vmName}`);
238
+ }
239
+
240
+ // ==================== Uncloud Config Management ====================
241
+
242
+ /**
243
+ * Upload uncloud config to platform
244
+ */
245
+ async uploadUncloudConfig(configYaml: string): Promise<void> {
246
+ await this.request('POST', '/api/uncloud/config', { config: configYaml });
247
+ }
248
+
249
+ /**
250
+ * Download uncloud config from platform
251
+ */
252
+ async downloadUncloudConfig(): Promise<string | null> {
253
+ try {
254
+ const { config } = await this.request<{ config: string }>('GET', '/api/uncloud/config');
255
+ return config;
256
+ } catch (error: any) {
257
+ if (error.message.includes('404') || error.message.includes('not found')) {
258
+ return null;
259
+ }
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ // ==================== Gateway Management ====================
265
+
266
+ /**
267
+ * Get gateway info for a location
268
+ */
269
+ async getGateway(location: string): Promise<{
270
+ vmId: string;
271
+ ipv4: string;
272
+ ipv6: string;
273
+ privateIpv6?: string; // Private subnet IPv6 for NAT64 routing
274
+ subnetId?: string; // Private subnet ID for app VMs
275
+ subnetName?: string;
276
+ wireguardPublicKey?: string; // WireGuard public key for NAT64 tunnel
277
+ wireguardPort?: number; // WireGuard listen port (default 51820)
278
+ tunnelId: string;
279
+ location: string;
280
+ } | null> {
281
+ try {
282
+ const { gateway } = await this.request<{
283
+ gateway: {
284
+ vmId: string;
285
+ ipv4: string;
286
+ ipv6: string;
287
+ privateIpv6?: string;
288
+ subnetId?: string;
289
+ subnetName?: string;
290
+ wireguardPublicKey?: string;
291
+ wireguardPort?: number;
292
+ tunnelId: string;
293
+ location: string;
294
+ };
295
+ }>('GET', `/api/gateway?location=${encodeURIComponent(location)}`);
296
+ return gateway;
297
+ } catch (error: any) {
298
+ if (error.message.includes('404') || error.message.includes('not found')) {
299
+ return null;
300
+ }
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ // ==================== Route Management ====================
306
+
307
+ /**
308
+ * Register a route for an app (maps hostname to VM IPv6)
309
+ */
310
+ async registerRoute(appName: string, backendIpv6: string, backendPort: number = 443): Promise<{
311
+ hostname: string;
312
+ fullUrl: string;
313
+ }> {
314
+ const { route } = await this.request<{
315
+ route: { hostname: string; fullUrl: string };
316
+ }>('POST', '/api/routes', {
317
+ appName,
318
+ backendIpv6,
319
+ backendPort,
320
+ });
321
+ return route;
322
+ }
323
+
324
+ /**
325
+ * Delete routes for an app
326
+ */
327
+ async deleteRoute(appName: string): Promise<void> {
328
+ await this.request('DELETE', `/api/routes/${encodeURIComponent(appName)}`);
329
+ }
330
+
331
+ // ==================== Gateway Route Sync ====================
332
+
333
+ /**
334
+ * Sync gateway Caddy configuration for a location
335
+ * This updates the gateway's reverse proxy config with current routes
336
+ */
337
+ async syncGatewayRoutes(location: string): Promise<{ routeCount: number }> {
338
+ const result = await this.request<{
339
+ success: boolean;
340
+ routeCount: number;
341
+ }>('POST', '/api/gateway/sync', { location });
342
+ return { routeCount: result.routeCount };
343
+ }
344
+
345
+ // ==================== SSH Certificate Management ====================
346
+
347
+ /**
348
+ * Get platform SSH keys (CA public key + platform public key for VM creation)
349
+ */
350
+ async getPlatformSSHKeys(): Promise<{
351
+ caPublicKey: string;
352
+ platformPublicKey: string;
353
+ }> {
354
+ return this.request<{
355
+ caPublicKey: string;
356
+ platformPublicKey: string;
357
+ }>('GET', '/api/platform/ssh-keys');
358
+ }
359
+
360
+ /**
361
+ * Initialize platform SSH keys (creates them if they don't exist)
362
+ */
363
+ async initPlatformSSHKeys(): Promise<{
364
+ caPublicKey: string;
365
+ platformPublicKey: string;
366
+ }> {
367
+ return this.request<{
368
+ success: boolean;
369
+ caPublicKey: string;
370
+ platformPublicKey: string;
371
+ }>('POST', '/api/platform/ssh-keys');
372
+ }
373
+
374
+ /**
375
+ * Request a signed SSH certificate for accessing an app's VMs
376
+ * Returns a short-lived certificate (5 min) signed by the platform CA
377
+ */
378
+ async requestSSHCertificate(appName: string, publicKey: string): Promise<{
379
+ certificate: string;
380
+ validityMinutes: number;
381
+ }> {
382
+ return this.request<{
383
+ certificate: string;
384
+ validityMinutes: number;
385
+ }>('POST', `/api/apps/${appName}/ssh-certificate`, { publicKey });
386
+ }
387
+
388
+ // ==================== VM Setup ====================
389
+
390
+ /**
391
+ * Setup a newly created VM (DNS64, NAT64, SSH CA, Docker, uncloud)
392
+ * This runs all configuration using the platform SSH key
393
+ */
394
+ async setupVM(vmIp: string, location: string, appName: string): Promise<void> {
395
+ await this.request<{ success: boolean }>('POST', '/api/vms/setup', {
396
+ vmIp,
397
+ location,
398
+ appName,
399
+ });
400
+ }
401
+
402
+ // ==================== Environment Variables ====================
403
+
404
+ /**
405
+ * Set a single environment variable
406
+ */
407
+ async setEnvVar(appName: string, key: string, value: string): Promise<void> {
408
+ await this.request('POST', `/api/apps/${appName}/env`, { key, value });
409
+ }
410
+
411
+ /**
412
+ * Set multiple environment variables at once
413
+ */
414
+ async setEnvVars(appName: string, vars: Record<string, string>): Promise<void> {
415
+ await this.request('POST', `/api/apps/${appName}/env`, { vars });
416
+ }
417
+
418
+ /**
419
+ * List environment variables (values are masked)
420
+ */
421
+ async listEnvVars(appName: string): Promise<Array<{ key: string; valueLength: number }>> {
422
+ const { vars } = await this.request<{
423
+ vars: Array<{ key: string; valueLength: number }>;
424
+ }>('GET', `/api/apps/${appName}/env`);
425
+ return vars;
426
+ }
427
+
428
+ /**
429
+ * Remove an environment variable
430
+ */
431
+ async unsetEnvVar(appName: string, key: string): Promise<void> {
432
+ await this.request('DELETE', `/api/apps/${appName}/env/${encodeURIComponent(key)}`);
433
+ }
434
+
435
+ // ==================== GitHub Connection ====================
436
+
437
+ /**
438
+ * Create app metadata without creating VM (for connect-before-deploy flow)
439
+ */
440
+ async createAppMetadata(appName: string, location?: string): Promise<AppCluster> {
441
+ const { app } = await this.request<{ app: AppCluster }>('POST', '/api/apps', {
442
+ appName,
443
+ location: location || 'eu-central-h1',
444
+ metadataOnly: true, // Signal that we don't want to create VM yet
445
+ });
446
+ return app;
447
+ }
448
+
449
+ /**
450
+ * Initiate GitHub App installation flow
451
+ */
452
+ async initiateGitHubConnect(): Promise<{ authUrl: string; stateToken: string }> {
453
+ return this.request<{ authUrl: string; stateToken: string }>('POST', '/api/github/connect');
454
+ }
455
+
456
+ /**
457
+ * Poll for GitHub App installation completion
458
+ */
459
+ async pollGitHubConnect(stateToken: string): Promise<{
460
+ status: 'pending' | 'complete' | 'expired';
461
+ installationId?: number;
462
+ }> {
463
+ return this.request<{
464
+ status: 'pending' | 'complete' | 'expired';
465
+ installationId?: number;
466
+ }>('GET', `/api/github/connect/poll?state=${encodeURIComponent(stateToken)}`);
467
+ }
468
+
469
+ /**
470
+ * Get current user's GitHub App installation
471
+ */
472
+ async getGitHubInstallation(): Promise<{
473
+ installationId: number;
474
+ accountLogin: string;
475
+ } | null> {
476
+ try {
477
+ const { installation } = await this.request<{
478
+ installation: {
479
+ installationId: number;
480
+ accountLogin: string;
481
+ } | null;
482
+ }>('GET', '/api/github/installation');
483
+ return installation;
484
+ } catch (error: any) {
485
+ if (error.message.includes('404') || error.message.includes('not found')) {
486
+ return null;
487
+ }
488
+ throw error;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * List repositories accessible via GitHub App installation
494
+ */
495
+ async listAccessibleRepos(): Promise<Array<{
496
+ fullName: string;
497
+ name: string;
498
+ private: boolean;
499
+ defaultBranch: string;
500
+ }>> {
501
+ const { repos } = await this.request<{
502
+ repos: Array<{
503
+ fullName: string;
504
+ name: string;
505
+ private: boolean;
506
+ defaultBranch: string;
507
+ }>;
508
+ }>('GET', '/api/github/repos');
509
+ return repos;
510
+ }
511
+
512
+ /**
513
+ * Connect a repository to an app
514
+ */
515
+ async connectRepo(appName: string, repoFullName: string, branch?: string): Promise<void> {
516
+ await this.request('POST', `/api/apps/${appName}/repo`, {
517
+ repoFullName,
518
+ branch: branch || 'main',
519
+ });
520
+ }
521
+
522
+ /**
523
+ * Get connected repository for an app
524
+ */
525
+ async getConnectedRepo(appName: string): Promise<{
526
+ repoFullName: string;
527
+ branch: string;
528
+ } | null> {
529
+ try {
530
+ const { repo } = await this.request<{
531
+ repo: {
532
+ repoFullName: string;
533
+ branch: string;
534
+ } | null;
535
+ }>('GET', `/api/apps/${appName}/repo`);
536
+ return repo;
537
+ } catch (error: any) {
538
+ if (error.message.includes('404') || error.message.includes('not found')) {
539
+ return null;
540
+ }
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Disconnect repository from an app
547
+ */
548
+ async disconnectRepo(appName: string): Promise<void> {
549
+ await this.request('DELETE', `/api/apps/${appName}/repo`);
550
+ }
551
+
552
+ // ==================== Builds ====================
553
+
554
+ /**
555
+ * List builds for an app
556
+ */
557
+ async listBuilds(appName: string, limit: number = 10): Promise<Array<{
558
+ id: number;
559
+ commitSha: string;
560
+ commitMsg: string | null;
561
+ branch: string;
562
+ status: string;
563
+ startedAt: string | null;
564
+ completedAt: string | null;
565
+ createdAt: string;
566
+ }>> {
567
+ const { builds } = await this.request<{
568
+ builds: Array<{
569
+ id: number;
570
+ commitSha: string;
571
+ commitMsg: string | null;
572
+ branch: string;
573
+ status: string;
574
+ startedAt: string | null;
575
+ completedAt: string | null;
576
+ createdAt: string;
577
+ }>;
578
+ }>('GET', `/api/apps/${appName}/builds?limit=${limit}`);
579
+ return builds;
580
+ }
581
+
582
+ /**
583
+ * Get build details
584
+ */
585
+ async getBuild(appName: string, buildId: number): Promise<{
586
+ id: number;
587
+ commitSha: string;
588
+ commitMsg: string | null;
589
+ branch: string;
590
+ status: string;
591
+ logs: string | null;
592
+ startedAt: string | null;
593
+ completedAt: string | null;
594
+ createdAt: string;
595
+ }> {
596
+ const { build } = await this.request<{
597
+ build: {
598
+ id: number;
599
+ commitSha: string;
600
+ commitMsg: string | null;
601
+ branch: string;
602
+ status: string;
603
+ logs: string | null;
604
+ startedAt: string | null;
605
+ completedAt: string | null;
606
+ createdAt: string;
607
+ };
608
+ }>('GET', `/api/apps/${appName}/builds/${buildId}`);
609
+ return build;
610
+ }
611
+
612
+ /**
613
+ * Get build events (for live streaming)
614
+ */
615
+ async getBuildEvents(appName: string, buildId: number, after?: Date): Promise<Array<{
616
+ id: number;
617
+ timestamp: string;
618
+ stage: string;
619
+ status: string;
620
+ message: string | null;
621
+ }>> {
622
+ let url = `/api/apps/${appName}/builds/${buildId}/events`;
623
+ if (after) {
624
+ url += `?after=${encodeURIComponent(after.toISOString())}`;
625
+ }
626
+ const { events } = await this.request<{
627
+ events: Array<{
628
+ id: number;
629
+ timestamp: string;
630
+ stage: string;
631
+ status: string;
632
+ message: string | null;
633
+ }>;
634
+ }>('GET', url);
635
+ return events;
636
+ }
637
+ }