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,314 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
5
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
6
|
+
import { getAppName } from '../lib/app-config.js';
|
|
7
|
+
|
|
8
|
+
function sleep(ms: number): Promise<void> {
|
|
9
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Format duration in human-readable form
|
|
14
|
+
*/
|
|
15
|
+
function formatDuration(startedAt: string | null, completedAt: string | null): string {
|
|
16
|
+
if (!startedAt) return '-';
|
|
17
|
+
|
|
18
|
+
const start = new Date(startedAt);
|
|
19
|
+
const end = completedAt ? new Date(completedAt) : new Date();
|
|
20
|
+
const durationMs = end.getTime() - start.getTime();
|
|
21
|
+
|
|
22
|
+
const seconds = Math.floor(durationMs / 1000);
|
|
23
|
+
if (seconds < 60) return `${seconds}s`;
|
|
24
|
+
|
|
25
|
+
const minutes = Math.floor(seconds / 60);
|
|
26
|
+
const remainingSeconds = seconds % 60;
|
|
27
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format relative time (e.g., "2 minutes ago")
|
|
32
|
+
*/
|
|
33
|
+
function formatRelativeTime(dateStr: string): string {
|
|
34
|
+
const date = new Date(dateStr);
|
|
35
|
+
const now = new Date();
|
|
36
|
+
const diffMs = now.getTime() - date.getTime();
|
|
37
|
+
const diffSeconds = Math.floor(diffMs / 1000);
|
|
38
|
+
|
|
39
|
+
if (diffSeconds < 60) return 'just now';
|
|
40
|
+
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)} minutes ago`;
|
|
41
|
+
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)} hours ago`;
|
|
42
|
+
return `${Math.floor(diffSeconds / 86400)} days ago`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get status emoji
|
|
47
|
+
*/
|
|
48
|
+
function getStatusIcon(status: string): string {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case 'success': return chalk.green('✓');
|
|
51
|
+
case 'failed': return chalk.red('✗');
|
|
52
|
+
case 'building': return chalk.yellow('●');
|
|
53
|
+
case 'pending': return chalk.dim('○');
|
|
54
|
+
default: return chalk.dim('?');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get stage emoji for live output
|
|
60
|
+
*/
|
|
61
|
+
function getStageIcon(stage: string, status: string): string {
|
|
62
|
+
if (status === 'completed') return chalk.green('✓');
|
|
63
|
+
if (status === 'failed') return chalk.red('✗');
|
|
64
|
+
if (status === 'started') return chalk.yellow('●');
|
|
65
|
+
return chalk.dim('○');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Format timestamp for live output
|
|
70
|
+
*/
|
|
71
|
+
function formatEventTime(startTime: Date, eventTime: Date): string {
|
|
72
|
+
const diffMs = eventTime.getTime() - startTime.getTime();
|
|
73
|
+
const totalSeconds = Math.floor(diffMs / 1000);
|
|
74
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
75
|
+
const seconds = totalSeconds % 60;
|
|
76
|
+
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createBuildsCommand() {
|
|
80
|
+
const cmd = new Command('builds');
|
|
81
|
+
cmd
|
|
82
|
+
.description('View and monitor builds')
|
|
83
|
+
.argument('[buildId]', 'Build ID to view details')
|
|
84
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml or folder name if not specified)')
|
|
85
|
+
.option('--watch', 'Watch for new builds')
|
|
86
|
+
.option('--latest', 'Stream the latest/current build in real-time')
|
|
87
|
+
.option('-n, --limit <number>', 'Number of builds to show', '10')
|
|
88
|
+
.action(async (buildId, options) => {
|
|
89
|
+
try {
|
|
90
|
+
const appName = options.app || getAppName();
|
|
91
|
+
const platformToken = getPlatformToken();
|
|
92
|
+
const platformClient = new PlatformClient(platformToken);
|
|
93
|
+
|
|
94
|
+
if (buildId) {
|
|
95
|
+
// Show specific build details
|
|
96
|
+
await showBuildDetails(platformClient, appName, parseInt(buildId, 10));
|
|
97
|
+
} else if (options.latest) {
|
|
98
|
+
// Stream latest build
|
|
99
|
+
await streamLatestBuild(platformClient, appName);
|
|
100
|
+
} else if (options.watch) {
|
|
101
|
+
// Watch for new builds
|
|
102
|
+
await watchBuilds(platformClient, appName);
|
|
103
|
+
} else {
|
|
104
|
+
// List builds
|
|
105
|
+
await listBuilds(platformClient, appName, parseInt(options.limit, 10));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return cmd;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List recent builds
|
|
119
|
+
*/
|
|
120
|
+
async function listBuilds(platformClient: PlatformClient, appName: string, limit: number) {
|
|
121
|
+
const spinner = ora('Fetching builds...').start();
|
|
122
|
+
const builds = await platformClient.listBuilds(appName, limit);
|
|
123
|
+
spinner.stop();
|
|
124
|
+
|
|
125
|
+
if (builds.length === 0) {
|
|
126
|
+
console.log(chalk.yellow(`\nNo builds found for '${appName}'.\n`));
|
|
127
|
+
console.log(chalk.cyan('Connect a GitHub repo to enable auto-deploy:\n'));
|
|
128
|
+
console.log(` hackerrun connect\n`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log(chalk.cyan(`\nBuilds for '${appName}':\n`));
|
|
133
|
+
|
|
134
|
+
for (const build of builds) {
|
|
135
|
+
const icon = getStatusIcon(build.status);
|
|
136
|
+
const sha = build.commitSha.substring(0, 7);
|
|
137
|
+
const msg = build.commitMsg
|
|
138
|
+
? (build.commitMsg.length > 50 ? build.commitMsg.substring(0, 47) + '...' : build.commitMsg)
|
|
139
|
+
: chalk.dim('No message');
|
|
140
|
+
const duration = formatDuration(build.startedAt, build.completedAt);
|
|
141
|
+
const time = formatRelativeTime(build.createdAt);
|
|
142
|
+
|
|
143
|
+
console.log(` ${icon} ${chalk.bold(`#${build.id}`)} ${chalk.dim(sha)} ${msg}`);
|
|
144
|
+
console.log(` ${chalk.dim(`${build.branch} • ${duration} • ${time}`)}`);
|
|
145
|
+
console.log();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Show details for a specific build
|
|
151
|
+
*/
|
|
152
|
+
async function showBuildDetails(platformClient: PlatformClient, appName: string, buildId: number) {
|
|
153
|
+
const spinner = ora('Fetching build details...').start();
|
|
154
|
+
const build = await platformClient.getBuild(appName, buildId);
|
|
155
|
+
const events = await platformClient.getBuildEvents(appName, buildId);
|
|
156
|
+
spinner.stop();
|
|
157
|
+
|
|
158
|
+
const icon = getStatusIcon(build.status);
|
|
159
|
+
const sha = build.commitSha.substring(0, 7);
|
|
160
|
+
|
|
161
|
+
console.log(chalk.cyan(`\nBuild #${build.id}\n`));
|
|
162
|
+
console.log(` Status: ${icon} ${build.status}`);
|
|
163
|
+
console.log(` Commit: ${chalk.dim(sha)} ${build.commitMsg || chalk.dim('No message')}`);
|
|
164
|
+
console.log(` Branch: ${build.branch}`);
|
|
165
|
+
console.log(` Duration: ${formatDuration(build.startedAt, build.completedAt)}`);
|
|
166
|
+
console.log(` Created: ${formatRelativeTime(build.createdAt)}`);
|
|
167
|
+
|
|
168
|
+
if (events.length > 0) {
|
|
169
|
+
console.log(chalk.cyan('\nBuild Events:\n'));
|
|
170
|
+
const startTime = new Date(events[0].timestamp);
|
|
171
|
+
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
const eventTime = new Date(event.timestamp);
|
|
174
|
+
const timeStr = formatEventTime(startTime, eventTime);
|
|
175
|
+
const icon = getStageIcon(event.stage, event.status);
|
|
176
|
+
console.log(` [${timeStr}] ${icon} ${event.message || event.stage}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (build.logs) {
|
|
181
|
+
console.log(chalk.cyan('\nBuild Logs:\n'));
|
|
182
|
+
console.log(chalk.dim(build.logs));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stream the latest build in real-time
|
|
190
|
+
*/
|
|
191
|
+
async function streamLatestBuild(platformClient: PlatformClient, appName: string) {
|
|
192
|
+
console.log(chalk.cyan(`\nWatching latest build for '${appName}'...\n`));
|
|
193
|
+
console.log(chalk.dim('Press Ctrl+C to stop\n'));
|
|
194
|
+
|
|
195
|
+
let currentBuildId: number | null = null;
|
|
196
|
+
let lastEventTimestamp: Date | null = null;
|
|
197
|
+
let buildStartTime: Date | null = null;
|
|
198
|
+
|
|
199
|
+
// Set up Ctrl+C handler
|
|
200
|
+
process.on('SIGINT', () => {
|
|
201
|
+
console.log(chalk.dim('\n\nStopped watching.\n'));
|
|
202
|
+
process.exit(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
while (true) {
|
|
206
|
+
try {
|
|
207
|
+
// Get latest builds
|
|
208
|
+
const builds = await platformClient.listBuilds(appName, 1);
|
|
209
|
+
|
|
210
|
+
if (builds.length === 0) {
|
|
211
|
+
console.log(chalk.dim('Waiting for builds...'));
|
|
212
|
+
await sleep(5000);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const latestBuild = builds[0];
|
|
217
|
+
|
|
218
|
+
// New build started
|
|
219
|
+
if (latestBuild.id !== currentBuildId) {
|
|
220
|
+
currentBuildId = latestBuild.id;
|
|
221
|
+
lastEventTimestamp = null;
|
|
222
|
+
buildStartTime = latestBuild.startedAt ? new Date(latestBuild.startedAt) : new Date();
|
|
223
|
+
|
|
224
|
+
console.log(chalk.bold(`\nBuild #${latestBuild.id}`) +
|
|
225
|
+
` - commit ${chalk.dim(latestBuild.commitSha.substring(0, 7))} "${latestBuild.commitMsg || 'No message'}"`);
|
|
226
|
+
console.log(chalk.dim(`Branch: ${latestBuild.branch} | Started: ${formatRelativeTime(latestBuild.createdAt)}`));
|
|
227
|
+
console.log();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Fetch new events
|
|
231
|
+
const events = await platformClient.getBuildEvents(appName, currentBuildId, lastEventTimestamp || undefined);
|
|
232
|
+
|
|
233
|
+
for (const event of events) {
|
|
234
|
+
const eventTime = new Date(event.timestamp);
|
|
235
|
+
const timeStr = formatEventTime(buildStartTime!, eventTime);
|
|
236
|
+
const icon = getStageIcon(event.stage, event.status);
|
|
237
|
+
console.log(`[${timeStr}] ${icon} ${event.message || event.stage}`);
|
|
238
|
+
lastEventTimestamp = eventTime;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if build is complete
|
|
242
|
+
if (latestBuild.status === 'success' || latestBuild.status === 'failed') {
|
|
243
|
+
const duration = formatDuration(latestBuild.startedAt, latestBuild.completedAt);
|
|
244
|
+
const icon = getStatusIcon(latestBuild.status);
|
|
245
|
+
|
|
246
|
+
console.log();
|
|
247
|
+
console.log(`${icon} Build #${latestBuild.id} ${latestBuild.status} in ${duration}`);
|
|
248
|
+
|
|
249
|
+
// Wait for next build
|
|
250
|
+
console.log(chalk.dim('\nWaiting for next build...\n'));
|
|
251
|
+
currentBuildId = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await sleep(2000); // Poll every 2 seconds
|
|
255
|
+
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// Ignore transient errors and keep polling
|
|
258
|
+
await sleep(5000);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Watch for new builds
|
|
265
|
+
*/
|
|
266
|
+
async function watchBuilds(platformClient: PlatformClient, appName: string) {
|
|
267
|
+
console.log(chalk.cyan(`\nWatching builds for '${appName}'...\n`));
|
|
268
|
+
console.log(chalk.dim('Press Ctrl+C to stop\n'));
|
|
269
|
+
|
|
270
|
+
let lastBuildId: number | null = null;
|
|
271
|
+
|
|
272
|
+
// Set up Ctrl+C handler
|
|
273
|
+
process.on('SIGINT', () => {
|
|
274
|
+
console.log(chalk.dim('\n\nStopped watching.\n'));
|
|
275
|
+
process.exit(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
while (true) {
|
|
279
|
+
try {
|
|
280
|
+
const builds = await platformClient.listBuilds(appName, 5);
|
|
281
|
+
|
|
282
|
+
if (builds.length === 0) {
|
|
283
|
+
console.log(chalk.dim('No builds yet. Waiting...'));
|
|
284
|
+
await sleep(5000);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check for new builds
|
|
289
|
+
const latestBuild = builds[0];
|
|
290
|
+
if (latestBuild.id !== lastBuildId) {
|
|
291
|
+
if (lastBuildId !== null) {
|
|
292
|
+
// New build detected
|
|
293
|
+
const icon = getStatusIcon(latestBuild.status);
|
|
294
|
+
const sha = latestBuild.commitSha.substring(0, 7);
|
|
295
|
+
console.log(`${icon} New build #${latestBuild.id} ${chalk.dim(sha)} "${latestBuild.commitMsg || 'No message'}" [${latestBuild.status}]`);
|
|
296
|
+
}
|
|
297
|
+
lastBuildId = latestBuild.id;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Show status updates for in-progress builds
|
|
301
|
+
for (const build of builds) {
|
|
302
|
+
if (build.status === 'building') {
|
|
303
|
+
const duration = formatDuration(build.startedAt, null);
|
|
304
|
+
console.log(chalk.yellow(` ● Build #${build.id} in progress (${duration})...`));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await sleep(5000);
|
|
309
|
+
|
|
310
|
+
} catch (error) {
|
|
311
|
+
await sleep(5000);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { ConfigManager } from '../lib/config.js';
|
|
4
|
+
|
|
5
|
+
export function createConfigCommand() {
|
|
6
|
+
const cmd = new Command('config');
|
|
7
|
+
cmd.description('Manage hackerrun configuration');
|
|
8
|
+
|
|
9
|
+
// config set
|
|
10
|
+
cmd
|
|
11
|
+
.command('set')
|
|
12
|
+
.description('Set a configuration value')
|
|
13
|
+
.argument('<key>', 'Configuration key (apiToken)')
|
|
14
|
+
.argument('<value>', 'Configuration value')
|
|
15
|
+
.action((key: string, value: string) => {
|
|
16
|
+
try {
|
|
17
|
+
const configManager = new ConfigManager();
|
|
18
|
+
|
|
19
|
+
if (key !== 'apiToken') {
|
|
20
|
+
console.log(chalk.red(`\n❌ Invalid key: ${key}\n`));
|
|
21
|
+
console.log(chalk.cyan('Valid keys:'));
|
|
22
|
+
console.log(' - apiToken Platform API token\n');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
configManager.set(key as any, value);
|
|
27
|
+
|
|
28
|
+
const displayValue = configManager.getAll(true).apiToken;
|
|
29
|
+
console.log(chalk.green(`\n✓ Set ${key} = ${displayValue}\n`));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// config get
|
|
37
|
+
cmd
|
|
38
|
+
.command('get')
|
|
39
|
+
.description('Get a configuration value')
|
|
40
|
+
.argument('<key>', 'Configuration key')
|
|
41
|
+
.option('--show-secrets', 'Show unmasked sensitive values')
|
|
42
|
+
.action((key: string, options) => {
|
|
43
|
+
try {
|
|
44
|
+
const configManager = new ConfigManager();
|
|
45
|
+
|
|
46
|
+
if (key !== 'apiToken') {
|
|
47
|
+
console.log(chalk.red(`\n❌ Invalid key: ${key}\n`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const value = configManager.get(key as any);
|
|
52
|
+
|
|
53
|
+
if (!value) {
|
|
54
|
+
console.log(chalk.yellow(`\n⚠️ ${key} is not set\n`));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const displayValue = options.showSecrets ? value : configManager.getAll(true).apiToken;
|
|
59
|
+
console.log(chalk.cyan(`\n${key} = ${displayValue}\n`));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// config list
|
|
67
|
+
cmd
|
|
68
|
+
.command('list')
|
|
69
|
+
.description('List all configuration values')
|
|
70
|
+
.option('--show-secrets', 'Show unmasked sensitive values')
|
|
71
|
+
.action((options) => {
|
|
72
|
+
try {
|
|
73
|
+
const configManager = new ConfigManager();
|
|
74
|
+
const config = configManager.getAll(!options.showSecrets);
|
|
75
|
+
|
|
76
|
+
if (Object.keys(config).length === 0) {
|
|
77
|
+
console.log(chalk.yellow('\n⚠️ No configuration found\n'));
|
|
78
|
+
console.log(chalk.cyan('Get started by logging in:\n'));
|
|
79
|
+
console.log(' hackerrun login\n');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(chalk.cyan('\n📝 Configuration:\n'));
|
|
84
|
+
console.log(` apiToken: ${config.apiToken || chalk.red('not set')}`);
|
|
85
|
+
console.log(chalk.dim(`\nConfig file: ${configManager.getConfigPath()}\n`));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// config unset
|
|
93
|
+
cmd
|
|
94
|
+
.command('unset')
|
|
95
|
+
.description('Remove a configuration value')
|
|
96
|
+
.argument('<key>', 'Configuration key')
|
|
97
|
+
.action((key: string) => {
|
|
98
|
+
try {
|
|
99
|
+
const configManager = new ConfigManager();
|
|
100
|
+
|
|
101
|
+
if (key !== 'apiToken') {
|
|
102
|
+
console.log(chalk.red(`\n❌ Invalid key: ${key}\n`));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
configManager.unset(key as any);
|
|
107
|
+
console.log(chalk.green(`\n✓ Removed ${key}\n`));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// config path
|
|
115
|
+
cmd
|
|
116
|
+
.command('path')
|
|
117
|
+
.description('Show the config file path')
|
|
118
|
+
.action(() => {
|
|
119
|
+
try {
|
|
120
|
+
const configManager = new ConfigManager();
|
|
121
|
+
console.log(configManager.getConfigPath());
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error(chalk.red(`\n❌ Error: ${(error as Error).message}\n`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return cmd;
|
|
129
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { select } from '@inquirer/prompts';
|
|
5
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
6
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
7
|
+
import { getAppName, linkApp, hasAppConfig } from '../lib/app-config.js';
|
|
8
|
+
|
|
9
|
+
function sleep(ms: number): Promise<void> {
|
|
10
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Poll for GitHub App installation completion
|
|
15
|
+
*/
|
|
16
|
+
async function pollForInstallation(
|
|
17
|
+
platformClient: PlatformClient,
|
|
18
|
+
stateToken: string,
|
|
19
|
+
spinner: ReturnType<typeof ora>,
|
|
20
|
+
maxAttempts: number = 60, // 5 minutes at 5s intervals
|
|
21
|
+
intervalMs: number = 5000
|
|
22
|
+
): Promise<{ installationId: number }> {
|
|
23
|
+
let attempts = 0;
|
|
24
|
+
|
|
25
|
+
while (attempts < maxAttempts) {
|
|
26
|
+
await sleep(intervalMs);
|
|
27
|
+
attempts++;
|
|
28
|
+
|
|
29
|
+
const result = await platformClient.pollGitHubConnect(stateToken);
|
|
30
|
+
|
|
31
|
+
if (result.status === 'complete' && result.installationId) {
|
|
32
|
+
return { installationId: result.installationId };
|
|
33
|
+
} else if (result.status === 'expired') {
|
|
34
|
+
throw new Error('Authorization expired. Please try again.');
|
|
35
|
+
}
|
|
36
|
+
// Status is 'pending', continue polling
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error('Authorization timed out. Please try again.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createConnectCommand() {
|
|
43
|
+
const cmd = new Command('connect');
|
|
44
|
+
cmd
|
|
45
|
+
.description('Connect a GitHub repository for automatic deploys')
|
|
46
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml or folder name if not specified)')
|
|
47
|
+
.action(async (options) => {
|
|
48
|
+
try {
|
|
49
|
+
// Get app name: --app flag > hackerrun.yaml > folder name
|
|
50
|
+
const appName = options.app || getAppName();
|
|
51
|
+
const platformToken = getPlatformToken();
|
|
52
|
+
const platformClient = new PlatformClient(platformToken);
|
|
53
|
+
|
|
54
|
+
console.log(chalk.cyan(`\nConnecting GitHub repository to '${appName}'\n`));
|
|
55
|
+
|
|
56
|
+
// Check if app exists, create metadata if not
|
|
57
|
+
let app = await platformClient.getApp(appName);
|
|
58
|
+
if (!app) {
|
|
59
|
+
const spinner = ora('Creating app...').start();
|
|
60
|
+
app = await platformClient.createAppMetadata(appName);
|
|
61
|
+
spinner.succeed(`Created app '${appName}'`);
|
|
62
|
+
|
|
63
|
+
// Create hackerrun.yaml if it doesn't exist
|
|
64
|
+
if (!hasAppConfig()) {
|
|
65
|
+
linkApp(appName);
|
|
66
|
+
console.log(chalk.dim(` Created hackerrun.yaml`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for existing repo connection
|
|
71
|
+
const existingRepo = await platformClient.getConnectedRepo(appName);
|
|
72
|
+
if (existingRepo) {
|
|
73
|
+
console.log(chalk.yellow(`\nApp '${appName}' is already connected to:`));
|
|
74
|
+
console.log(` ${chalk.bold(existingRepo.repoFullName)} (branch: ${existingRepo.branch})\n`);
|
|
75
|
+
|
|
76
|
+
const action = await select({
|
|
77
|
+
message: 'What would you like to do?',
|
|
78
|
+
choices: [
|
|
79
|
+
{ name: 'Change repository', value: 'change' },
|
|
80
|
+
{ name: 'Keep current connection', value: 'keep' },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (action === 'keep') {
|
|
85
|
+
console.log(chalk.green('\nConnection unchanged.\n'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check for GitHub App installation
|
|
91
|
+
let installation = await platformClient.getGitHubInstallation();
|
|
92
|
+
|
|
93
|
+
if (!installation) {
|
|
94
|
+
// Need to install GitHub App
|
|
95
|
+
console.log(chalk.cyan('First, install the HackerRun GitHub App:\n'));
|
|
96
|
+
|
|
97
|
+
const spinner = ora('Initiating GitHub connection...').start();
|
|
98
|
+
const flow = await platformClient.initiateGitHubConnect();
|
|
99
|
+
spinner.succeed('GitHub connection initiated');
|
|
100
|
+
|
|
101
|
+
console.log(chalk.cyan('\nPlease complete the following steps:\n'));
|
|
102
|
+
console.log(` 1. Visit: ${chalk.bold.blue(flow.authUrl)}`);
|
|
103
|
+
console.log(` 2. Install the HackerRun app on your account`);
|
|
104
|
+
console.log(` 3. Select the repositories you want to access\n`);
|
|
105
|
+
|
|
106
|
+
const pollSpinner = ora('Waiting for GitHub authorization...').start();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
installation = await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
|
|
110
|
+
pollSpinner.succeed('GitHub App installed');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
pollSpinner.fail((error as Error).message);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
console.log(chalk.green(`✓ GitHub App already installed (${installation.accountLogin})\n`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fetch accessible repos
|
|
120
|
+
const repoSpinner = ora('Fetching accessible repositories...').start();
|
|
121
|
+
const repos = await platformClient.listAccessibleRepos();
|
|
122
|
+
repoSpinner.stop();
|
|
123
|
+
|
|
124
|
+
if (repos.length === 0) {
|
|
125
|
+
console.log(chalk.yellow('\nNo repositories found.'));
|
|
126
|
+
console.log(chalk.cyan('Make sure you have given HackerRun access to your repositories.\n'));
|
|
127
|
+
console.log(`Visit ${chalk.blue('https://github.com/settings/installations')} to manage access.\n`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Select repository
|
|
132
|
+
const selectedRepo = await select({
|
|
133
|
+
message: 'Select repository to connect:',
|
|
134
|
+
choices: repos.map(r => ({
|
|
135
|
+
name: `${r.fullName}${r.private ? chalk.dim(' (private)') : ''} [${r.defaultBranch}]`,
|
|
136
|
+
value: r.fullName,
|
|
137
|
+
})),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Get default branch for the selected repo
|
|
141
|
+
const selectedRepoInfo = repos.find(r => r.fullName === selectedRepo);
|
|
142
|
+
const branch = selectedRepoInfo?.defaultBranch || 'main';
|
|
143
|
+
|
|
144
|
+
// Connect repository
|
|
145
|
+
const connectSpinner = ora('Connecting repository...').start();
|
|
146
|
+
await platformClient.connectRepo(appName, selectedRepo, branch);
|
|
147
|
+
connectSpinner.succeed(`Connected ${selectedRepo}`);
|
|
148
|
+
|
|
149
|
+
console.log(chalk.green(`\n✓ Successfully connected!\n`));
|
|
150
|
+
console.log(chalk.cyan('Auto-deploy is now enabled:'));
|
|
151
|
+
console.log(` Repository: ${chalk.bold(selectedRepo)}`);
|
|
152
|
+
console.log(` Branch: ${chalk.bold(branch)}`);
|
|
153
|
+
console.log(` App: ${chalk.bold(appName)}\n`);
|
|
154
|
+
console.log(chalk.dim('Every push to this branch will trigger a build and deploy.\n'));
|
|
155
|
+
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return cmd;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createDisconnectCommand() {
|
|
166
|
+
const cmd = new Command('disconnect');
|
|
167
|
+
cmd
|
|
168
|
+
.description('Disconnect GitHub repository from an app')
|
|
169
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml or folder name if not specified)')
|
|
170
|
+
.action(async (options) => {
|
|
171
|
+
try {
|
|
172
|
+
const appName = options.app || getAppName();
|
|
173
|
+
const platformToken = getPlatformToken();
|
|
174
|
+
const platformClient = new PlatformClient(platformToken);
|
|
175
|
+
|
|
176
|
+
// Check for existing connection
|
|
177
|
+
const existingRepo = await platformClient.getConnectedRepo(appName);
|
|
178
|
+
if (!existingRepo) {
|
|
179
|
+
console.log(chalk.yellow(`\nNo repository connected to '${appName}'.\n`));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const spinner = ora('Disconnecting repository...').start();
|
|
184
|
+
await platformClient.disconnectRepo(appName);
|
|
185
|
+
spinner.succeed('Repository disconnected');
|
|
186
|
+
|
|
187
|
+
console.log(chalk.green(`\n✓ Disconnected ${existingRepo.repoFullName} from '${appName}'\n`));
|
|
188
|
+
console.log(chalk.dim('Auto-deploy is now disabled. Use `hackerrun connect` to reconnect.\n'));
|
|
189
|
+
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return cmd;
|
|
197
|
+
}
|