nexusforge-cli 1.0.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,222 @@
1
+ /**
2
+ * NexusForge CLI Authentication Service
3
+ *
4
+ * Handles device authorization flow (like Claude Code):
5
+ * 1. CLI requests device code
6
+ * 2. CLI displays verification URL
7
+ * 3. User opens URL in browser and authorizes
8
+ * 4. CLI polls for authorization
9
+ * 5. CLI saves token on success
10
+ */
11
+
12
+ import open from 'open';
13
+ import ora from 'ora';
14
+ import { style, displaySystemMessage } from '../utils/theme.js';
15
+ import * as config from '../utils/config.js';
16
+ import type { DeviceCodeResponse, DeviceAuthStatus } from '../types/index.js';
17
+
18
+ const POLL_INTERVAL_MS = 5000; // 5 seconds
19
+ const MAX_POLL_ATTEMPTS = 180; // 15 minutes max
20
+
21
+ /**
22
+ * Start the device authorization flow
23
+ */
24
+ export async function startDeviceAuth(): Promise<boolean> {
25
+ const apiUrl = config.getApiUrl();
26
+
27
+ console.log('');
28
+ displaySystemMessage('Starting authentication...', 'info');
29
+ console.log('');
30
+
31
+ try {
32
+ // Step 1: Request device code
33
+ const deviceCodeResponse = await requestDeviceCode(apiUrl);
34
+
35
+ // Step 2: Display verification URL
36
+ console.log(style.divider());
37
+ console.log('');
38
+ console.log(style.assistant(' To authenticate, open this URL in your browser:'));
39
+ console.log('');
40
+ console.log(style.prompt(` ${deviceCodeResponse.verification_uri_complete}`));
41
+ console.log('');
42
+ console.log(style.muted(` Or go to ${deviceCodeResponse.verification_uri}`));
43
+ console.log(style.muted(` and enter code: ${style.prompt(deviceCodeResponse.user_code)}`));
44
+ console.log('');
45
+ console.log(style.divider());
46
+ console.log('');
47
+
48
+ // Try to open browser automatically
49
+ try {
50
+ await open(deviceCodeResponse.verification_uri_complete);
51
+ displaySystemMessage('Browser opened automatically', 'info');
52
+ } catch {
53
+ displaySystemMessage('Could not open browser. Please copy the URL above.', 'warning');
54
+ }
55
+
56
+ console.log('');
57
+
58
+ // Step 3: Poll for authorization
59
+ const spinner = ora({
60
+ text: 'Waiting for authorization...',
61
+ color: 'cyan',
62
+ }).start();
63
+
64
+ const authResult = await pollForAuthorization(
65
+ apiUrl,
66
+ deviceCodeResponse.device_code,
67
+ deviceCodeResponse.interval * 1000,
68
+ spinner,
69
+ );
70
+
71
+ if (authResult) {
72
+ spinner.succeed(style.success('Authenticated successfully!'));
73
+
74
+ // Save auth data
75
+ config.saveAuth({
76
+ accessToken: authResult.access_token!,
77
+ refreshToken: authResult.refresh_token,
78
+ userId: authResult.user?.id,
79
+ username: authResult.user?.username,
80
+ email: authResult.user?.email,
81
+ });
82
+
83
+ console.log('');
84
+ displaySystemMessage(`Welcome, ${authResult.user?.username || 'user'}!`, 'success');
85
+ console.log('');
86
+
87
+ return true;
88
+ } else {
89
+ spinner.fail(style.error('Authentication failed or timed out'));
90
+ return false;
91
+ }
92
+ } catch (error) {
93
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
94
+ displaySystemMessage(`Authentication error: ${errorMessage}`, 'error');
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Request a device code from the API
101
+ */
102
+ async function requestDeviceCode(apiUrl: string): Promise<DeviceCodeResponse> {
103
+ const response = await fetch(`${apiUrl}/cli/auth/device`, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ body: JSON.stringify({ client_id: 'nexusforge-cli' }),
109
+ });
110
+
111
+ if (!response.ok) {
112
+ throw new Error(`Failed to get device code: ${response.statusText}`);
113
+ }
114
+
115
+ return response.json() as Promise<DeviceCodeResponse>;
116
+ }
117
+
118
+ /**
119
+ * Poll for authorization status
120
+ */
121
+ async function pollForAuthorization(
122
+ apiUrl: string,
123
+ deviceCode: string,
124
+ intervalMs: number,
125
+ spinner: ReturnType<typeof ora>,
126
+ ): Promise<DeviceAuthStatus | null> {
127
+ let attempts = 0;
128
+
129
+ while (attempts < MAX_POLL_ATTEMPTS) {
130
+ attempts++;
131
+
132
+ await sleep(intervalMs);
133
+
134
+ try {
135
+ const response = await fetch(`${apiUrl}/cli/auth/device/${deviceCode}`);
136
+
137
+ if (!response.ok) {
138
+ continue;
139
+ }
140
+
141
+ const status = await response.json() as DeviceAuthStatus;
142
+
143
+ switch (status.status) {
144
+ case 'authorized':
145
+ return status;
146
+
147
+ case 'denied':
148
+ spinner.fail(style.error('Authorization denied by user'));
149
+ return null;
150
+
151
+ case 'expired':
152
+ spinner.fail(style.error('Authorization code expired'));
153
+ return null;
154
+
155
+ case 'pending':
156
+ // Keep polling
157
+ spinner.text = `Waiting for authorization... (${Math.floor(attempts * intervalMs / 1000)}s)`;
158
+ break;
159
+ }
160
+ } catch {
161
+ // Network error, keep trying
162
+ spinner.text = 'Waiting for authorization... (connection issue, retrying)';
163
+ }
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * Check if current token is valid
171
+ */
172
+ export async function checkAuth(): Promise<boolean> {
173
+ const token = config.getAccessToken();
174
+
175
+ if (!token) {
176
+ return false;
177
+ }
178
+
179
+ const apiUrl = config.getApiUrl();
180
+
181
+ try {
182
+ // Try to get user info with current token
183
+ const response = await fetch(`${apiUrl}/auth/me`, {
184
+ headers: {
185
+ Authorization: `Bearer ${token}`,
186
+ },
187
+ });
188
+
189
+ return response.ok;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Logout - clear stored credentials
197
+ */
198
+ export function logout(): void {
199
+ config.clearAuth();
200
+ displaySystemMessage('Logged out successfully', 'success');
201
+ }
202
+
203
+ /**
204
+ * Get current user info
205
+ */
206
+ export function getCurrentUser(): { username?: string; email?: string } | null {
207
+ if (!config.isAuthenticated()) {
208
+ return null;
209
+ }
210
+
211
+ return {
212
+ username: config.get('username'),
213
+ email: config.get('email'),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Sleep helper
219
+ */
220
+ function sleep(ms: number): Promise<void> {
221
+ return new Promise((resolve) => setTimeout(resolve, ms));
222
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * NexusForge CLI Command Executor
3
+ *
4
+ * Executes shell commands and streams output.
5
+ * Provides Claude Code-like command execution capabilities.
6
+ */
7
+
8
+ import { spawn, exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+ import { style, displaySystemMessage } from '../utils/theme.js';
11
+ import * as api from './api.js';
12
+ import type { CommandResult } from '../types/index.js';
13
+
14
+ const execAsync = promisify(exec);
15
+
16
+ // Dangerous commands that should be blocked or warned
17
+ const DANGEROUS_COMMANDS = [
18
+ 'rm -rf /',
19
+ 'rm -rf ~',
20
+ 'rm -rf *',
21
+ 'mkfs',
22
+ ':(){:|:&};:',
23
+ 'dd if=/dev/zero',
24
+ 'chmod -R 777 /',
25
+ '> /dev/sda',
26
+ ];
27
+
28
+ const DANGEROUS_PATTERNS = [
29
+ /rm\s+-rf\s+\/(?!\w)/,
30
+ /curl.*\|.*(?:bash|sh)/,
31
+ /wget.*\|.*(?:bash|sh)/,
32
+ />\s*\/dev\/sd/,
33
+ ];
34
+
35
+ /**
36
+ * Check if a command is potentially dangerous
37
+ */
38
+ export function isDangerous(command: string): boolean {
39
+ const normalizedCmd = command.toLowerCase().trim();
40
+
41
+ // Check exact matches
42
+ if (DANGEROUS_COMMANDS.some((dangerous) => normalizedCmd.includes(dangerous))) {
43
+ return true;
44
+ }
45
+
46
+ // Check patterns
47
+ return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command));
48
+ }
49
+
50
+ /**
51
+ * Execute a command and return the result
52
+ */
53
+ export async function executeCommand(
54
+ command: string,
55
+ cwd?: string,
56
+ ): Promise<CommandResult> {
57
+ const startTime = Date.now();
58
+ const workingDir = cwd || process.cwd();
59
+
60
+ try {
61
+ const { stdout, stderr } = await execAsync(command, {
62
+ cwd: workingDir,
63
+ timeout: 300000, // 5 minute timeout
64
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
65
+ });
66
+
67
+ const duration = Date.now() - startTime;
68
+
69
+ // Log to API (fire and forget)
70
+ api.logCommandExecution({
71
+ command,
72
+ workingDirectory: workingDir,
73
+ exitCode: 0,
74
+ output: stdout.substring(0, 1000),
75
+ duration,
76
+ }).catch(() => {});
77
+
78
+ return {
79
+ command,
80
+ exitCode: 0,
81
+ stdout,
82
+ stderr,
83
+ duration,
84
+ };
85
+ } catch (error: unknown) {
86
+ const duration = Date.now() - startTime;
87
+ const execError = error as { code?: number; stdout?: string; stderr?: string };
88
+
89
+ // Log to API
90
+ api.logCommandExecution({
91
+ command,
92
+ workingDirectory: workingDir,
93
+ exitCode: execError.code || 1,
94
+ output: execError.stderr?.substring(0, 1000),
95
+ duration,
96
+ }).catch(() => {});
97
+
98
+ return {
99
+ command,
100
+ exitCode: execError.code || 1,
101
+ stdout: execError.stdout || '',
102
+ stderr: execError.stderr || (error instanceof Error ? error.message : 'Unknown error'),
103
+ duration,
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Execute a command with streaming output
110
+ */
111
+ export async function executeCommandStream(
112
+ command: string,
113
+ cwd?: string,
114
+ onStdout?: (data: string) => void,
115
+ onStderr?: (data: string) => void,
116
+ ): Promise<CommandResult> {
117
+ const startTime = Date.now();
118
+ const workingDir = cwd || process.cwd();
119
+
120
+ return new Promise((resolve) => {
121
+ // Determine shell based on platform
122
+ const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
123
+ const shellArgs = process.platform === 'win32' ? ['/c', command] : ['-c', command];
124
+
125
+ const child = spawn(shell, shellArgs, {
126
+ cwd: workingDir,
127
+ env: process.env,
128
+ });
129
+
130
+ let stdout = '';
131
+ let stderr = '';
132
+
133
+ child.stdout.on('data', (data: Buffer) => {
134
+ const text = data.toString();
135
+ stdout += text;
136
+ if (onStdout) {
137
+ onStdout(text);
138
+ }
139
+ });
140
+
141
+ child.stderr.on('data', (data: Buffer) => {
142
+ const text = data.toString();
143
+ stderr += text;
144
+ if (onStderr) {
145
+ onStderr(text);
146
+ }
147
+ });
148
+
149
+ child.on('close', (code) => {
150
+ const duration = Date.now() - startTime;
151
+ const exitCode = code ?? 0;
152
+
153
+ // Log to API
154
+ api.logCommandExecution({
155
+ command,
156
+ workingDirectory: workingDir,
157
+ exitCode,
158
+ output: stdout.substring(0, 1000),
159
+ duration,
160
+ }).catch(() => {});
161
+
162
+ resolve({
163
+ command,
164
+ exitCode,
165
+ stdout,
166
+ stderr,
167
+ duration,
168
+ });
169
+ });
170
+
171
+ child.on('error', (error) => {
172
+ const duration = Date.now() - startTime;
173
+
174
+ resolve({
175
+ command,
176
+ exitCode: 1,
177
+ stdout: '',
178
+ stderr: error.message,
179
+ duration,
180
+ });
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Display command result in terminal
187
+ */
188
+ export function displayCommandResult(result: CommandResult): void {
189
+ console.log('');
190
+ console.log(style.muted(`$ ${result.command}`));
191
+ console.log('');
192
+
193
+ if (result.stdout) {
194
+ console.log(result.stdout);
195
+ }
196
+
197
+ if (result.stderr && result.exitCode !== 0) {
198
+ console.log(style.error(result.stderr));
199
+ }
200
+
201
+ const statusColor = result.exitCode === 0 ? 'success' : 'error';
202
+ displaySystemMessage(
203
+ `Exit code: ${result.exitCode} (${result.duration}ms)`,
204
+ statusColor,
205
+ );
206
+ console.log('');
207
+ }
208
+
209
+ /**
210
+ * Parse AI response for executable commands
211
+ */
212
+ export function parseCommandsFromResponse(content: string): string[] {
213
+ const commands: string[] = [];
214
+
215
+ // Match code blocks with bash/shell/sh language
216
+ const codeBlockRegex = /```(?:bash|shell|sh|zsh|terminal)\n([\s\S]*?)```/g;
217
+ let match;
218
+
219
+ while ((match = codeBlockRegex.exec(content)) !== null) {
220
+ const code = match[1].trim();
221
+ // Split by newlines and filter empty lines
222
+ const lines = code.split('\n').filter((line) => {
223
+ const trimmed = line.trim();
224
+ // Skip comments and empty lines
225
+ return trimmed && !trimmed.startsWith('#');
226
+ });
227
+ commands.push(...lines);
228
+ }
229
+
230
+ return commands;
231
+ }
232
+
233
+ /**
234
+ * Get current working directory
235
+ */
236
+ export function getCwd(): string {
237
+ return process.cwd();
238
+ }
239
+
240
+ /**
241
+ * Change working directory
242
+ */
243
+ export function changeCwd(path: string): boolean {
244
+ try {
245
+ process.chdir(path);
246
+ return true;
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * NexusForge CLI Type Definitions
3
+ */
4
+
5
+ // Configuration stored in ~/.nexusforge/config.json
6
+ export interface CLIConfig {
7
+ apiUrl: string;
8
+ accessToken?: string;
9
+ refreshToken?: string;
10
+ userId?: number;
11
+ username?: string;
12
+ email?: string;
13
+ defaultModel: string;
14
+ theme: 'dark' | 'light';
15
+ autoExecute: boolean;
16
+ }
17
+
18
+ // Device authorization flow
19
+ export interface DeviceCodeResponse {
20
+ device_code: string;
21
+ user_code: string;
22
+ verification_uri: string;
23
+ verification_uri_complete: string;
24
+ expires_in: number;
25
+ interval: number;
26
+ }
27
+
28
+ export interface DeviceAuthStatus {
29
+ status: 'pending' | 'authorized' | 'expired' | 'denied';
30
+ access_token?: string;
31
+ refresh_token?: string;
32
+ token_type?: string;
33
+ expires_in?: number;
34
+ user?: {
35
+ id: number;
36
+ username: string;
37
+ email: string;
38
+ };
39
+ }
40
+
41
+ // Session management
42
+ export interface CLISession {
43
+ session_id: string;
44
+ session_token: string;
45
+ conversation_id: string;
46
+ expires_at: string;
47
+ user_id: number;
48
+ sync_command: string;
49
+ }
50
+
51
+ // Chat messages
52
+ export interface ChatMessage {
53
+ role: 'user' | 'assistant' | 'system';
54
+ content: string;
55
+ timestamp?: string;
56
+ }
57
+
58
+ export interface ChatResponse {
59
+ conversation_id: string;
60
+ message: ChatMessage;
61
+ suggested_actions?: SuggestedAction[];
62
+ }
63
+
64
+ // Suggested actions from AI
65
+ export interface SuggestedAction {
66
+ type: 'command' | 'file_edit' | 'file_create' | 'file_read';
67
+ description: string;
68
+ command?: string;
69
+ path?: string;
70
+ content?: string;
71
+ diff?: string;
72
+ }
73
+
74
+ // File context for AI
75
+ export interface FileContext {
76
+ path: string;
77
+ content: string;
78
+ language?: string;
79
+ }
80
+
81
+ // WebSocket message types
82
+ export interface WSMessage {
83
+ type: 'message' | 'chunk' | 'done' | 'error' | 'ping' | 'pong';
84
+ content?: string;
85
+ conversation_id?: string;
86
+ message?: string;
87
+ }
88
+
89
+ // Slash command
90
+ export interface SlashCommand {
91
+ name: string;
92
+ description: string;
93
+ aliases?: string[];
94
+ handler: (args: string[]) => Promise<void>;
95
+ }
96
+
97
+ // Command execution result
98
+ export interface CommandResult {
99
+ command: string;
100
+ exitCode: number;
101
+ stdout: string;
102
+ stderr: string;
103
+ duration: number;
104
+ }