@codecora/cli 0.0.3

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,255 @@
1
+ /**
2
+ * Authentication Commands
3
+ *
4
+ * Handles login/logout for CLI via GitHub OAuth.
5
+ */
6
+ import { createApiClient } from '../api/client.js';
7
+ import { getLocalConfig, isAuthenticated, setAuthToken, clearAuthToken, setLocalConfig, } from '../config/storage.js';
8
+ import * as http from 'node:http';
9
+ import * as os from 'node:os';
10
+ /**
11
+ * Login command
12
+ *
13
+ * Opens browser for GitHub OAuth flow.
14
+ * The server now exchanges the OAuth code and returns the session token directly.
15
+ */
16
+ export async function login(serverUrl) {
17
+ // Check if already logged in
18
+ if (await isAuthenticated()) {
19
+ const config = await getLocalConfig();
20
+ console.log(`Already logged in as ${config.user?.email}`);
21
+ console.log('Run "cora auth logout" to sign out first.');
22
+ return;
23
+ }
24
+ // Get server URL from config or parameter
25
+ const config = await getLocalConfig();
26
+ const targetServerUrl = serverUrl || config.auth?.serverUrl || 'https://codecora.dev';
27
+ console.log(`Logging in to ${targetServerUrl}...`);
28
+ // Create local HTTP server for callback
29
+ const { port, promise } = createCallbackServer();
30
+ // Build OAuth URL
31
+ const redirectUri = `http://localhost:${port}/callback`;
32
+ const authUrl = `${targetServerUrl}/api/auth/cli?redirect_uri=${encodeURIComponent(redirectUri)}`;
33
+ // Open browser
34
+ console.log('\nOpening browser for GitHub authorization...');
35
+ console.log(`If browser doesn't open, visit:\n ${authUrl}\n`);
36
+ try {
37
+ await openBrowser(authUrl);
38
+ }
39
+ catch (error) {
40
+ console.log(`\nPlease open this URL in your browser:\n ${authUrl}\n`);
41
+ }
42
+ // Wait for callback
43
+ console.log('Waiting for authorization...');
44
+ try {
45
+ const authResult = await promise;
46
+ if (authResult instanceof Error) {
47
+ throw authResult;
48
+ }
49
+ // Store session directly from server response
50
+ await setAuthToken(authResult.token, targetServerUrl, authResult.userId, authResult.email, authResult.expiresAt);
51
+ console.log(`\nSuccessfully logged in as ${authResult.email}`);
52
+ process.exit(0);
53
+ }
54
+ catch (error) {
55
+ throw new Error(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ }
58
+ /**
59
+ * Logout command
60
+ *
61
+ * Clears stored session token.
62
+ */
63
+ export async function logout() {
64
+ if (!(await isAuthenticated())) {
65
+ console.log('Not logged in.');
66
+ return;
67
+ }
68
+ const config = await getLocalConfig();
69
+ console.log(`Logging out ${config.user?.email}...`);
70
+ await clearAuthToken();
71
+ console.log('Logged out successfully.');
72
+ }
73
+ /**
74
+ * Status command
75
+ *
76
+ * Shows current authentication status.
77
+ */
78
+ export async function status() {
79
+ const config = await getLocalConfig();
80
+ if (!config.auth?.sessionToken) {
81
+ console.log('Status: Not logged in');
82
+ console.log('Run "cora auth login" to authenticate.');
83
+ return;
84
+ }
85
+ console.log('Status: Authenticated');
86
+ console.log(`Email: ${config.user?.email || 'Unknown'}`);
87
+ console.log(`User ID: ${config.user?.id || 'Unknown'}`);
88
+ console.log(`Server: ${config.auth?.serverUrl || 'Unknown'}`);
89
+ // Verify session with server
90
+ try {
91
+ const client = await createApiClient();
92
+ const verification = await client.verifySession();
93
+ if (verification.valid) {
94
+ console.log('Session: Valid');
95
+ if (verification.expiresAt) {
96
+ const expires = new Date(verification.expiresAt);
97
+ console.log(`Expires: ${expires.toLocaleString()}`);
98
+ }
99
+ }
100
+ else {
101
+ console.log('Session: Invalid or expired');
102
+ console.log('Please run "cora auth login" to re-authenticate.');
103
+ }
104
+ }
105
+ catch (error) {
106
+ console.log('Session verification failed:', error instanceof Error ? error.message : String(error));
107
+ }
108
+ }
109
+ /**
110
+ * Config command
111
+ *
112
+ * Set or view configuration.
113
+ */
114
+ export async function config(key, value) {
115
+ const currentConfig = await getLocalConfig();
116
+ if (!key) {
117
+ // Show all config
118
+ console.log('Current configuration:');
119
+ console.log(JSON.stringify(currentConfig, null, 2));
120
+ return;
121
+ }
122
+ if (value === undefined) {
123
+ // Show specific config value
124
+ const keys = key.split('.');
125
+ let currentValue = currentConfig;
126
+ for (const k of keys) {
127
+ currentValue = currentValue?.[k];
128
+ }
129
+ console.log(`${key}: ${currentValue !== undefined ? JSON.stringify(currentValue) : 'not set'}`);
130
+ return;
131
+ }
132
+ // Set config value
133
+ console.log(`Setting ${key} = ${value}`);
134
+ const updates = {};
135
+ let updateTarget = updates;
136
+ const keys = key.split('.');
137
+ for (let i = 0; i < keys.length - 1; i++) {
138
+ const k = keys[i];
139
+ if (k) {
140
+ updateTarget[k] = {};
141
+ updateTarget = updateTarget[k];
142
+ }
143
+ }
144
+ const lastKey = keys[keys.length - 1];
145
+ if (lastKey) {
146
+ updateTarget[lastKey] = value;
147
+ }
148
+ await setLocalConfig({ ...currentConfig, ...updates });
149
+ console.log('Configuration updated.');
150
+ }
151
+ /**
152
+ * Create local HTTP server for OAuth callback
153
+ *
154
+ * Uses a fixed port (4200) for GitHub OAuth redirect URI configuration.
155
+ * GitHub OAuth requires pre-configured redirect URIs, so we use
156
+ * http://localhost:4200/callback consistently.
157
+ *
158
+ * The server now receives the session token directly from the server
159
+ * instead of the OAuth code, simplifying the flow.
160
+ */
161
+ function createCallbackServer() {
162
+ let resolvePromise;
163
+ const promise = new Promise((resolve) => {
164
+ resolvePromise = resolve;
165
+ });
166
+ // Use fixed port for GitHub OAuth compatibility
167
+ // Users must add http://localhost:4200/callback to their GitHub App redirect URIs
168
+ const FIXED_PORT = 4200;
169
+ const server = http.createServer((req, res) => {
170
+ if (req.url?.startsWith('/callback')) {
171
+ const url = new URL(req.url, `http://localhost:${FIXED_PORT}`);
172
+ const token = url.searchParams.get('token');
173
+ const userId = url.searchParams.get('userId');
174
+ const email = url.searchParams.get('email');
175
+ const expiresAt = url.searchParams.get('expiresAt');
176
+ const error = url.searchParams.get('error');
177
+ if (error) {
178
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
179
+ res.end(`Authentication error: ${error}`);
180
+ resolvePromise(error);
181
+ server.close();
182
+ return;
183
+ }
184
+ if (token && userId && email && expiresAt) {
185
+ res.writeHead(200, { 'Content-Type': 'text/html' });
186
+ res.end(`
187
+ <!DOCTYPE html>
188
+ <html>
189
+ <head><title>Authentication Successful</title></head>
190
+ <body>
191
+ <h1>Authentication Successful!</h1>
192
+ <p>You can close this window and return to the terminal.</p>
193
+ </body>
194
+ </html>
195
+ `);
196
+ resolvePromise({ token, userId, email, expiresAt });
197
+ server.close();
198
+ }
199
+ else {
200
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
201
+ res.end('Missing required parameters');
202
+ resolvePromise(new Error('Missing required parameters'));
203
+ server.close();
204
+ }
205
+ }
206
+ });
207
+ server.listen(FIXED_PORT, () => {
208
+ // Server started on fixed port
209
+ });
210
+ // Handle port already in use
211
+ server.on('error', (error) => {
212
+ if (error.code === 'EADDRINUSE') {
213
+ resolvePromise(new Error(`Port ${FIXED_PORT} is already in use. Please stop the other process and try again.`));
214
+ }
215
+ else {
216
+ resolvePromise(error);
217
+ }
218
+ server.close();
219
+ });
220
+ return { port: FIXED_PORT, promise };
221
+ }
222
+ /**
223
+ * Open browser for OAuth flow
224
+ */
225
+ async function openBrowser(url) {
226
+ const platform = os.platform();
227
+ let command;
228
+ let args;
229
+ switch (platform) {
230
+ case 'darwin':
231
+ command = 'open';
232
+ args = [url];
233
+ break;
234
+ case 'win32':
235
+ command = 'cmd';
236
+ args = ['/c', 'start', '', url];
237
+ break;
238
+ default:
239
+ command = 'xdg-open';
240
+ args = [url];
241
+ break;
242
+ }
243
+ const { execFile } = await import('node:child_process');
244
+ return new Promise((resolve, reject) => {
245
+ execFile(command, args, (error) => {
246
+ if (error) {
247
+ // Browser open failed, but user can open URL manually
248
+ reject(error);
249
+ }
250
+ else {
251
+ resolve();
252
+ }
253
+ });
254
+ });
255
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Hook Commands
3
+ *
4
+ * Manage git hooks for automatic code review.
5
+ */
6
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { execFileSafe } from '../utils/exec.js';
9
+ /**
10
+ * Hook script template
11
+ */
12
+ const HOOK_SCRIPT = `#!/bin/sh
13
+ # CORA Pre-commit Hook
14
+ # Auto-generated by cora cli
15
+
16
+ # Skip if CORA_SKIP is set
17
+ if [ -n "$CORA_SKIP" ]; then
18
+ exit 0
19
+ fi
20
+
21
+ # Run cora review
22
+ npx cora review --staged --quiet
23
+
24
+ # Exit with cora's exit code
25
+ exit $?
26
+ `;
27
+ /**
28
+ * Install git hook
29
+ *
30
+ * Installs pre-commit hook for automatic review.
31
+ */
32
+ export async function installHook(type = 'pre-commit') {
33
+ const gitDir = getGitDir();
34
+ if (!gitDir) {
35
+ throw new Error('Not in a git repository');
36
+ }
37
+ const hooksDir = path.join(gitDir, 'hooks');
38
+ // Create hooks directory if it doesn't exist
39
+ if (!existsSync(hooksDir)) {
40
+ mkdirSync(hooksDir, { recursive: true });
41
+ }
42
+ const hookPath = path.join(hooksDir, type);
43
+ // Check if hook already exists
44
+ if (existsSync(hookPath)) {
45
+ const existing = readFileSync(hookPath, 'utf-8');
46
+ if (existing.includes('# CORA Pre-commit Hook')) {
47
+ console.log(`Hook already installed: ${hookPath}`);
48
+ console.log('Use "cora hook uninstall" to remove it first.');
49
+ return;
50
+ }
51
+ // Backup existing hook
52
+ const backupPath = `${hookPath}.backup`;
53
+ writeFileSync(backupPath, existing, { mode: 0o755 });
54
+ console.log(`Backed up existing hook to: ${backupPath}`);
55
+ }
56
+ // Write hook script
57
+ writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
58
+ console.log(`Installed ${type} hook: ${hookPath}`);
59
+ console.log('\nHook will run automatically on git ${type}.');
60
+ console.log('Skip temporarily with: CORA_SKIP=1 git commit');
61
+ }
62
+ /**
63
+ * Uninstall git hook
64
+ *
65
+ * Removes pre-commit hook.
66
+ */
67
+ export async function uninstallHook(type = 'pre-commit') {
68
+ const gitDir = getGitDir();
69
+ if (!gitDir) {
70
+ throw new Error('Not in a git repository');
71
+ }
72
+ const hookPath = path.join(gitDir, 'hooks', type);
73
+ if (!existsSync(hookPath)) {
74
+ console.log(`Hook not installed: ${type}`);
75
+ return;
76
+ }
77
+ // Check if it's a cora hook
78
+ const content = readFileSync(hookPath, 'utf-8');
79
+ if (!content.includes('# CORA Pre-commit Hook')) {
80
+ console.log(`Hook exists but was not installed by cora: ${type}`);
81
+ console.log('To remove it manually, delete:', hookPath);
82
+ return;
83
+ }
84
+ // Remove hook
85
+ unlinkSync(hookPath);
86
+ console.log(`Uninstalled ${type} hook`);
87
+ // Restore backup if exists
88
+ const backupPath = `${hookPath}.backup`;
89
+ if (existsSync(backupPath)) {
90
+ const backup = readFileSync(backupPath, 'utf-8');
91
+ writeFileSync(hookPath, backup, { mode: 0o755 });
92
+ console.log(`Restored backup from: ${backupPath}`);
93
+ }
94
+ }
95
+ /**
96
+ * List installed hooks
97
+ */
98
+ export async function listHooks() {
99
+ const gitDir = getGitDir();
100
+ if (!gitDir) {
101
+ throw new Error('Not in a git repository');
102
+ }
103
+ const hooksDir = path.join(gitDir, 'hooks');
104
+ const hookTypes = ['pre-commit', 'pre-push'];
105
+ console.log('Installed hooks:\n');
106
+ let found = false;
107
+ for (const type of hookTypes) {
108
+ const hookPath = path.join(hooksDir, type);
109
+ if (!existsSync(hookPath)) {
110
+ console.log(` ${type}: Not installed`);
111
+ continue;
112
+ }
113
+ const content = readFileSync(hookPath, 'utf-8');
114
+ const isCoraHook = content.includes('# CORA Pre-commit Hook');
115
+ if (isCoraHook) {
116
+ console.log(` ${type}: āœ“ Installed`);
117
+ found = true;
118
+ }
119
+ else {
120
+ console.log(` ${type}: Other hook (not managed by cora)`);
121
+ found = true;
122
+ }
123
+ }
124
+ if (!found) {
125
+ console.log(' No hooks installed');
126
+ }
127
+ }
128
+ /**
129
+ * Get .git directory path
130
+ */
131
+ function getGitDir() {
132
+ // Try git rev-parse --git-dir
133
+ const result = execFileSafe('git', ['rev-parse', '--git-dir']);
134
+ if (result.status === 0 && result.stdout.trim()) {
135
+ return result.stdout.trim();
136
+ }
137
+ // Fallback to .git in current directory
138
+ const cwd = process.cwd();
139
+ const gitPath = path.join(cwd, '.git');
140
+ if (existsSync(gitPath)) {
141
+ return gitPath;
142
+ }
143
+ return null;
144
+ }
145
+ /**
146
+ * Enable auto-review
147
+ *
148
+ * Alias for installHook for convenience.
149
+ */
150
+ export async function enable() {
151
+ await installHook();
152
+ }
153
+ /**
154
+ * Disable auto-review
155
+ *
156
+ * Alias for uninstallHook for convenience.
157
+ */
158
+ export async function disable() {
159
+ await uninstallHook();
160
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI Commands Export
3
+ *
4
+ * Central export for all CLI commands.
5
+ */
6
+ export { login, logout, status, config as authConfig } from './auth.js';
7
+ export { review, autoReview } from './review.js';
8
+ export { installHook, uninstallHook, listHooks, enable, disable } from './hook.js';
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Review Command
3
+ *
4
+ * Main command for reviewing code diffs.
5
+ */
6
+ import { CLIApiClient, createApiClient } from '../api/client.js';
7
+ import { isAuthenticated } from '../config/storage.js';
8
+ import { getReviewDiff, getRepoInfo, validateDiffSize, isGitRepo, } from '../git/diff.js';
9
+ /**
10
+ * Review code changes
11
+ *
12
+ * @param options - Review options from command line
13
+ * @returns Exit code
14
+ */
15
+ export async function review(options = {}) {
16
+ // Check authentication
17
+ if (!(await isAuthenticated())) {
18
+ console.error('Error: Not authenticated. Run "cora auth login" first.');
19
+ return 2; // CLIExitCode.AuthError
20
+ }
21
+ // Check if in git repo
22
+ const cwd = process.cwd();
23
+ if (!isGitRepo(cwd)) {
24
+ console.error('Error: Not in a git repository.');
25
+ return 1; // CLIExitCode.GeneralError
26
+ }
27
+ // Get repository info
28
+ const repoInfo = getRepoInfo(cwd);
29
+ const repository = options.repository || `${repoInfo.owner}/${repoInfo.repo}`;
30
+ const branch = options.branch || repoInfo.branch;
31
+ // Get diff
32
+ console.log('Fetching code changes...');
33
+ let diff;
34
+ try {
35
+ diff = getReviewDiff({
36
+ staged: options.staged !== false,
37
+ files: options.files,
38
+ cwd,
39
+ });
40
+ }
41
+ catch (error) {
42
+ console.error(`Error: Failed to get diff: ${error instanceof Error ? error.message : String(error)}`);
43
+ return 1; // CLIExitCode.GeneralError
44
+ }
45
+ if (!diff.trim()) {
46
+ console.log('No changes to review.');
47
+ return 0; // CLIExitCode.Success
48
+ }
49
+ // Validate diff size
50
+ const sizeCheck = validateDiffSize(diff);
51
+ if (!sizeCheck.valid) {
52
+ console.error(`Error: ${sizeCheck.error}`);
53
+ return 1; // CLIExitCode.GeneralError
54
+ }
55
+ console.log(`Diff size: ${(sizeCheck.size / 1024).toFixed(2)} KB`);
56
+ // Mock mode for testing
57
+ if (options.mock) {
58
+ return mockReview(diff, options);
59
+ }
60
+ // Create API client
61
+ let client;
62
+ try {
63
+ client = await createApiClient();
64
+ }
65
+ catch (error) {
66
+ console.error(`Error: Failed to create API client: ${error instanceof Error ? error.message : String(error)}`);
67
+ return 1; // CLIExitCode.GeneralError
68
+ }
69
+ // Send review request
70
+ console.log('Reviewing code...');
71
+ try {
72
+ const response = await client.reviewDiff({
73
+ diff,
74
+ workspaceId: options.workspace,
75
+ repository,
76
+ branch,
77
+ });
78
+ return displayResults(response, options.format);
79
+ }
80
+ catch (error) {
81
+ const apiError = error;
82
+ if (apiError.statusCode === 429) {
83
+ console.error('Error: Quota exceeded. Please upgrade your plan or wait for quota reset.');
84
+ return 3; // CLIExitCode.QuotaExceeded
85
+ }
86
+ console.error(`Error: Review failed: ${apiError.message || String(error)}`);
87
+ return 1; // CLIExitCode.GeneralError
88
+ }
89
+ }
90
+ /**
91
+ * Display review results
92
+ */
93
+ function displayResults(response, format = 'pretty') {
94
+ const { success, issues, summary, walkthrough, tokensUsed, quotaRemaining, quotaResetAt, shouldBlock, blockReason } = response;
95
+ // Clear the "Reviewing code..." line
96
+ process.stdout.write('\r' + ' '.repeat(50) + '\r');
97
+ // Display summary
98
+ console.log('\n' + '='.repeat(60));
99
+ console.log('REVIEW SUMMARY');
100
+ console.log('='.repeat(60));
101
+ console.log(summary);
102
+ console.log('='.repeat(60));
103
+ // Group issues by severity
104
+ const bySeverity = {
105
+ critical: issues.filter((i) => i.severity === 'critical'),
106
+ major: issues.filter((i) => i.severity === 'major'),
107
+ minor: issues.filter((i) => i.severity === 'minor'),
108
+ info: issues.filter((i) => i.severity === 'info'),
109
+ };
110
+ // Display issues by severity
111
+ for (const severity of ['critical', 'major', 'minor', 'info']) {
112
+ const severityIssues = bySeverity[severity] ?? [];
113
+ if (severityIssues.length === 0)
114
+ continue;
115
+ console.log(`\n${severity.toUpperCase()} (${severityIssues.length}):`);
116
+ console.log('-'.repeat(60));
117
+ for (const issue of severityIssues) {
118
+ displayIssue(issue, format);
119
+ }
120
+ }
121
+ // Display walkthrough if requested
122
+ if (walkthrough) {
123
+ console.log('\n' + '='.repeat(60));
124
+ console.log('WALKTHROUGH');
125
+ console.log('='.repeat(60));
126
+ console.log(walkthrough);
127
+ console.log('='.repeat(60));
128
+ }
129
+ // Display usage info
130
+ console.log(`\nTokens used: ${tokensUsed.toLocaleString()}`);
131
+ console.log(`Quota remaining: ${quotaRemaining.toLocaleString()}`);
132
+ if (quotaResetAt) {
133
+ const resetDate = new Date(quotaResetAt);
134
+ console.log(`Quota resets: ${resetDate.toLocaleString()}`);
135
+ }
136
+ // Handle blocking
137
+ if (shouldBlock) {
138
+ console.error(`\nāŒ COMMIT BLOCKED: ${blockReason || 'Issues found that must be resolved.'}`);
139
+ return 4; // CLIExitCode.BlockedByIssues
140
+ }
141
+ if (issues.some((i) => i.severity === 'critical')) {
142
+ console.log('\nāš ļø Critical issues found. Please review before committing.');
143
+ }
144
+ return success ? 0 : 1;
145
+ }
146
+ /**
147
+ * Display a single issue
148
+ */
149
+ function displayIssue(issue, format) {
150
+ const location = issue.line ? `${issue.file}:${issue.line}` : issue.file;
151
+ if (format === 'json') {
152
+ console.log(JSON.stringify(issue));
153
+ return;
154
+ }
155
+ if (format === 'compact') {
156
+ console.log(` [${issue.severity.toUpperCase()}] ${location}: ${issue.title}`);
157
+ return;
158
+ }
159
+ // Pretty format (default)
160
+ const severityIcons = {
161
+ critical: 'šŸ”“',
162
+ major: '🟠',
163
+ minor: '🟔',
164
+ info: 'šŸ”µ',
165
+ };
166
+ console.log(`\n${severityIcons[issue.severity] || '•'} ${issue.title}`);
167
+ console.log(` Location: ${location}`);
168
+ console.log(` Type: ${issue.issueType}`);
169
+ if (issue.body) {
170
+ console.log(` Description: ${issue.body}`);
171
+ }
172
+ if (issue.suggestedFix) {
173
+ console.log(` Suggested fix: ${issue.suggestedFix}`);
174
+ }
175
+ }
176
+ /**
177
+ * Mock review for testing
178
+ */
179
+ function mockReview(diff, options) {
180
+ console.log('Running in MOCK mode (no API call)');
181
+ const lines = diff.split('\n').length;
182
+ const files = diff.match(/^diff --git a\/(.+?) b/m)?.[1] || 'unknown';
183
+ return displayResults({
184
+ success: true,
185
+ issues: [
186
+ {
187
+ file: files,
188
+ line: 1,
189
+ severity: 'info',
190
+ issueType: 'best_practice',
191
+ title: 'Mock Review (Testing Mode)',
192
+ body: `This is a mock review result. The actual diff has ${lines} lines.`,
193
+ suggestedFix: 'Run without --mock flag for real review.',
194
+ },
195
+ ],
196
+ summary: 'Mock review completed successfully.',
197
+ walkthrough: options.includeWalkthrough ? 'This is a mock walkthrough for testing purposes.' : undefined,
198
+ tokensUsed: 100,
199
+ quotaRemaining: 99900,
200
+ shouldBlock: false,
201
+ }, options.format);
202
+ }
203
+ /**
204
+ * Auto-review for pre-commit hook
205
+ *
206
+ * Simplified output suitable for git hooks.
207
+ */
208
+ export async function autoReview(options = {}) {
209
+ // Force quiet mode for hooks
210
+ const result = await review({
211
+ ...options,
212
+ format: 'compact',
213
+ });
214
+ return result;
215
+ }