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.
- package/core/__tests__/agentic/memory-system.test.ts +2 -1
- package/core/__tests__/agentic/plan-mode.test.ts +2 -1
- package/core/agentic/agent-router.ts +21 -75
- package/core/command-registry/setup-commands.ts +15 -0
- package/core/domain/agent-generator.ts +9 -17
- package/core/infrastructure/path-manager.ts +23 -1
- package/core/storage/ideas-storage.ts +4 -0
- package/core/storage/queue-storage.ts +4 -0
- package/core/storage/shipped-storage.ts +4 -0
- package/core/storage/state-storage.ts +4 -0
- package/core/storage/storage-manager.ts +10 -7
- package/core/sync/auth-config.ts +145 -0
- package/core/sync/index.ts +30 -0
- package/core/sync/oauth-handler.ts +148 -0
- package/core/sync/sync-client.ts +252 -0
- package/core/sync/sync-manager.ts +358 -0
- package/package.json +1 -1
- package/templates/commands/auth.md +234 -0
- package/templates/commands/sync.md +91 -0
|
@@ -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
|