prjct-cli 0.17.0 → 0.18.1

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,148 @@
1
+ /**
2
+ * OAuth Handler - Manages authentication flow for CLI
3
+ *
4
+ * Two modes:
5
+ * 1. Simple: User copies API key from web dashboard
6
+ * 2. Browser (future): Full OAuth device flow
7
+ */
8
+
9
+ import { authConfig } from './auth-config'
10
+ import { syncClient } from './sync-client'
11
+
12
+ export interface AuthResult {
13
+ success: boolean
14
+ email?: string
15
+ error?: string
16
+ }
17
+
18
+ class OAuthHandler {
19
+ /**
20
+ * Start authentication flow
21
+ * Opens browser to dashboard where user can create/copy API key
22
+ */
23
+ async startAuthFlow(): Promise<{ url: string }> {
24
+ const apiUrl = await authConfig.getApiUrl()
25
+ // Dashboard URL where user can get their API key
26
+ const dashboardUrl = apiUrl.replace('api.', 'app.')
27
+ const authUrl = `${dashboardUrl}/settings/api-keys`
28
+
29
+ return { url: authUrl }
30
+ }
31
+
32
+ /**
33
+ * Save API key after user provides it
34
+ */
35
+ async saveApiKey(apiKey: string): Promise<AuthResult> {
36
+ // Validate format
37
+ if (!apiKey.startsWith('prjct_')) {
38
+ return {
39
+ success: false,
40
+ error: 'Invalid API key format. Keys start with "prjct_"',
41
+ }
42
+ }
43
+
44
+ // Save temporarily to test connection
45
+ await authConfig.write({ apiKey })
46
+
47
+ // Test the key by making a request
48
+ const isValid = await syncClient.testConnection()
49
+
50
+ if (!isValid) {
51
+ // Clear the invalid key
52
+ await authConfig.clearAuth()
53
+ return {
54
+ success: false,
55
+ error: 'API key is invalid or expired',
56
+ }
57
+ }
58
+
59
+ // Key is valid - fetch user info
60
+ try {
61
+ const userInfo = await this.fetchUserInfo(apiKey)
62
+
63
+ await authConfig.saveAuth(apiKey, userInfo.id, userInfo.email)
64
+
65
+ return {
66
+ success: true,
67
+ email: userInfo.email,
68
+ }
69
+ } catch {
70
+ // Key works but couldn't fetch user info - still save it
71
+ await authConfig.write({ apiKey })
72
+ return {
73
+ success: true,
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Fetch user info using the API key
80
+ */
81
+ private async fetchUserInfo(
82
+ apiKey: string
83
+ ): Promise<{ id: string; email: string; name: string }> {
84
+ const apiUrl = await authConfig.getApiUrl()
85
+
86
+ const response = await fetch(`${apiUrl}/auth/me`, {
87
+ headers: {
88
+ 'X-Api-Key': apiKey,
89
+ },
90
+ })
91
+
92
+ if (!response.ok) {
93
+ throw new Error('Failed to fetch user info')
94
+ }
95
+
96
+ const data = (await response.json()) as {
97
+ user: { id: string; email: string; name: string }
98
+ }
99
+ return data.user
100
+ }
101
+
102
+ /**
103
+ * Check if currently authenticated
104
+ */
105
+ async isAuthenticated(): Promise<boolean> {
106
+ return await authConfig.hasAuth()
107
+ }
108
+
109
+ /**
110
+ * Get current auth status
111
+ */
112
+ async getStatus(): Promise<{
113
+ authenticated: boolean
114
+ email: string | null
115
+ apiKeyPrefix: string | null
116
+ }> {
117
+ return await authConfig.getStatus()
118
+ }
119
+
120
+ /**
121
+ * Logout - clear all auth data
122
+ */
123
+ async logout(): Promise<void> {
124
+ await authConfig.clearAuth()
125
+ }
126
+
127
+ /**
128
+ * Open URL in default browser
129
+ */
130
+ async openBrowser(url: string): Promise<void> {
131
+ const { exec } = await import('child_process')
132
+ const { promisify } = await import('util')
133
+ const execAsync = promisify(exec)
134
+
135
+ const platform = process.platform
136
+ const command =
137
+ platform === 'darwin'
138
+ ? `open "${url}"`
139
+ : platform === 'win32'
140
+ ? `start "${url}"`
141
+ : `xdg-open "${url}"`
142
+
143
+ await execAsync(command)
144
+ }
145
+ }
146
+
147
+ export const oauthHandler = new OAuthHandler()
148
+ export default oauthHandler
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Sync Client - HTTP client for prjct API
3
+ *
4
+ * Handles communication with the backend API for push/pull operations.
5
+ * Uses native fetch API (available in Node 18+ and Bun).
6
+ */
7
+
8
+ import authConfig from './auth-config'
9
+ import type { SyncEvent } from '../events'
10
+
11
+ // ============================================
12
+ // Types
13
+ // ============================================
14
+
15
+ export interface SyncBatchResult {
16
+ success: boolean
17
+ processed: number
18
+ errors: Array<{ index: number; error: string }>
19
+ syncedAt: string
20
+ }
21
+
22
+ export interface SyncPullResult {
23
+ events: Array<{
24
+ type: string
25
+ path: string[]
26
+ data: unknown
27
+ timestamp: string
28
+ }>
29
+ syncedAt: string
30
+ }
31
+
32
+ export interface SyncStatus {
33
+ projectId: string
34
+ lastSync: string | null
35
+ pendingCount: number
36
+ hasConflicts: boolean
37
+ }
38
+
39
+ export interface SyncClientError {
40
+ code: 'AUTH_REQUIRED' | 'NETWORK_ERROR' | 'API_ERROR' | 'UNKNOWN'
41
+ message: string
42
+ status?: number
43
+ }
44
+
45
+ // ============================================
46
+ // Sync Client
47
+ // ============================================
48
+
49
+ class SyncClient {
50
+ private retryConfig = {
51
+ maxRetries: 3,
52
+ baseDelayMs: 1000,
53
+ maxDelayMs: 30000,
54
+ }
55
+
56
+ /**
57
+ * Push local events to the server
58
+ */
59
+ async pushEvents(projectId: string, events: SyncEvent[]): Promise<SyncBatchResult> {
60
+ const { apiUrl, apiKey } = await this.getAuthHeaders()
61
+
62
+ if (!apiKey) {
63
+ throw this.createError('AUTH_REQUIRED', 'No API key configured')
64
+ }
65
+
66
+ const response = await this.fetchWithRetry(`${apiUrl}/sync/batch`, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ 'X-Api-Key': apiKey,
71
+ },
72
+ body: JSON.stringify({
73
+ projectId,
74
+ events: events.map((e) => ({
75
+ type: e.type,
76
+ path: e.path,
77
+ data: e.data,
78
+ timestamp: e.timestamp,
79
+ })),
80
+ }),
81
+ })
82
+
83
+ if (!response.ok) {
84
+ const error = await this.parseErrorResponse(response)
85
+ throw error
86
+ }
87
+
88
+ return (await response.json()) as SyncBatchResult
89
+ }
90
+
91
+ /**
92
+ * Pull events from the server since a timestamp
93
+ */
94
+ async pullEvents(projectId: string, since?: string): Promise<SyncPullResult> {
95
+ const { apiUrl, apiKey } = await this.getAuthHeaders()
96
+
97
+ if (!apiKey) {
98
+ throw this.createError('AUTH_REQUIRED', 'No API key configured')
99
+ }
100
+
101
+ const response = await this.fetchWithRetry(`${apiUrl}/sync/pull`, {
102
+ method: 'POST',
103
+ headers: {
104
+ 'Content-Type': 'application/json',
105
+ 'X-Api-Key': apiKey,
106
+ },
107
+ body: JSON.stringify({
108
+ projectId,
109
+ since,
110
+ }),
111
+ })
112
+
113
+ if (!response.ok) {
114
+ const error = await this.parseErrorResponse(response)
115
+ throw error
116
+ }
117
+
118
+ return (await response.json()) as SyncPullResult
119
+ }
120
+
121
+ /**
122
+ * Get sync status for a project
123
+ */
124
+ async getStatus(projectId: string): Promise<SyncStatus> {
125
+ const { apiUrl, apiKey } = await this.getAuthHeaders()
126
+
127
+ if (!apiKey) {
128
+ throw this.createError('AUTH_REQUIRED', 'No API key configured')
129
+ }
130
+
131
+ const response = await this.fetchWithRetry(`${apiUrl}/sync/status/${projectId}`, {
132
+ method: 'GET',
133
+ headers: {
134
+ 'X-Api-Key': apiKey,
135
+ },
136
+ })
137
+
138
+ if (!response.ok) {
139
+ const error = await this.parseErrorResponse(response)
140
+ throw error
141
+ }
142
+
143
+ return (await response.json()) as SyncStatus
144
+ }
145
+
146
+ /**
147
+ * Test connection to the API
148
+ */
149
+ async testConnection(): Promise<boolean> {
150
+ try {
151
+ const { apiUrl, apiKey } = await this.getAuthHeaders()
152
+
153
+ if (!apiKey) {
154
+ return false
155
+ }
156
+
157
+ const response = await fetch(`${apiUrl}/health`, {
158
+ method: 'GET',
159
+ headers: {
160
+ 'X-Api-Key': apiKey,
161
+ },
162
+ })
163
+
164
+ return response.ok
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Check if we have valid authentication
172
+ */
173
+ async hasAuth(): Promise<boolean> {
174
+ return await authConfig.hasAuth()
175
+ }
176
+
177
+ // ============================================
178
+ // Private helpers
179
+ // ============================================
180
+
181
+ private async getAuthHeaders(): Promise<{ apiUrl: string; apiKey: string | null }> {
182
+ const [apiUrl, apiKey] = await Promise.all([authConfig.getApiUrl(), authConfig.getApiKey()])
183
+ return { apiUrl, apiKey }
184
+ }
185
+
186
+ private async fetchWithRetry(
187
+ url: string,
188
+ options: RequestInit,
189
+ retryCount = 0
190
+ ): Promise<Response> {
191
+ try {
192
+ const response = await fetch(url, options)
193
+
194
+ // Retry on server errors (5xx) but not client errors (4xx)
195
+ if (response.status >= 500 && retryCount < this.retryConfig.maxRetries) {
196
+ const delay = Math.min(
197
+ this.retryConfig.baseDelayMs * Math.pow(2, retryCount),
198
+ this.retryConfig.maxDelayMs
199
+ )
200
+ await this.sleep(delay)
201
+ return this.fetchWithRetry(url, options, retryCount + 1)
202
+ }
203
+
204
+ return response
205
+ } catch (error) {
206
+ // Retry on network errors
207
+ if (retryCount < this.retryConfig.maxRetries) {
208
+ const delay = Math.min(
209
+ this.retryConfig.baseDelayMs * Math.pow(2, retryCount),
210
+ this.retryConfig.maxDelayMs
211
+ )
212
+ await this.sleep(delay)
213
+ return this.fetchWithRetry(url, options, retryCount + 1)
214
+ }
215
+
216
+ throw this.createError(
217
+ 'NETWORK_ERROR',
218
+ error instanceof Error ? error.message : 'Network request failed'
219
+ )
220
+ }
221
+ }
222
+
223
+ private async parseErrorResponse(response: Response): Promise<SyncClientError> {
224
+ try {
225
+ const body = (await response.json()) as { message?: string; error?: string }
226
+ const message = body.message || body.error || `HTTP ${response.status}`
227
+
228
+ if (response.status === 401 || response.status === 403) {
229
+ return this.createError('AUTH_REQUIRED', message, response.status)
230
+ }
231
+
232
+ return this.createError('API_ERROR', message, response.status)
233
+ } catch {
234
+ return this.createError('API_ERROR', `HTTP ${response.status}`, response.status)
235
+ }
236
+ }
237
+
238
+ private createError(
239
+ code: SyncClientError['code'],
240
+ message: string,
241
+ status?: number
242
+ ): SyncClientError {
243
+ return { code, message, status }
244
+ }
245
+
246
+ private sleep(ms: number): Promise<void> {
247
+ return new Promise((resolve) => setTimeout(resolve, ms))
248
+ }
249
+ }
250
+
251
+ export const syncClient = new SyncClient()
252
+ export default syncClient