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,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
+ }