hackerrun 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +22 -0
- package/.env.example +9 -0
- package/CLAUDE.md +532 -0
- package/README.md +94 -0
- package/dist/index.js +2813 -0
- package/package.json +38 -0
- package/src/commands/app.ts +394 -0
- package/src/commands/builds.ts +314 -0
- package/src/commands/config.ts +129 -0
- package/src/commands/connect.ts +197 -0
- package/src/commands/deploy.ts +227 -0
- package/src/commands/env.ts +174 -0
- package/src/commands/login.ts +120 -0
- package/src/commands/logs.ts +97 -0
- package/src/index.ts +43 -0
- package/src/lib/app-config.ts +95 -0
- package/src/lib/cluster.ts +428 -0
- package/src/lib/config.ts +137 -0
- package/src/lib/platform-auth.ts +20 -0
- package/src/lib/platform-client.ts +637 -0
- package/src/lib/platform.ts +87 -0
- package/src/lib/ssh-cert.ts +264 -0
- package/src/lib/uncloud-runner.ts +342 -0
- package/src/lib/uncloud.ts +149 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,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
|
+
}
|