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,358 @@
1
+ /**
2
+ * Sync Manager - Orchestrates push/pull operations
3
+ *
4
+ * Main entry point for sync operations.
5
+ * Handles the coordination between local storage (EventBus) and remote API (SyncClient).
6
+ */
7
+
8
+ import { syncClient, type SyncBatchResult, type SyncPullResult, type SyncStatus } from './sync-client'
9
+ import authConfig from './auth-config'
10
+ import eventBus, { type SyncEvent } from '../events'
11
+ import { stateStorage } from '../storage/state-storage'
12
+ import { queueStorage } from '../storage/queue-storage'
13
+ import { ideasStorage, type IdeaPriority } from '../storage/ideas-storage'
14
+ import { shippedStorage } from '../storage/shipped-storage'
15
+ import type { TaskType, Priority, TaskSection } from '../schemas/state'
16
+
17
+ // ============================================
18
+ // Types
19
+ // ============================================
20
+
21
+ export interface SyncResult {
22
+ success: boolean
23
+ skipped: boolean
24
+ reason?: 'no_auth' | 'no_pending' | 'error'
25
+ pushed?: {
26
+ count: number
27
+ syncedAt: string
28
+ }
29
+ pulled?: {
30
+ count: number
31
+ syncedAt: string
32
+ }
33
+ error?: string
34
+ }
35
+
36
+ export interface PushResult {
37
+ success: boolean
38
+ skipped: boolean
39
+ reason?: 'no_auth' | 'no_pending' | 'error'
40
+ count?: number
41
+ syncedAt?: string
42
+ error?: string
43
+ }
44
+
45
+ export interface PullResult {
46
+ success: boolean
47
+ skipped: boolean
48
+ reason?: 'no_auth' | 'error'
49
+ count?: number
50
+ applied?: number
51
+ syncedAt?: string
52
+ error?: string
53
+ }
54
+
55
+ // ============================================
56
+ // Sync Manager
57
+ // ============================================
58
+
59
+ class SyncManager {
60
+ /**
61
+ * Check if user is authenticated
62
+ */
63
+ async hasAuth(): Promise<boolean> {
64
+ return await authConfig.hasAuth()
65
+ }
66
+
67
+ /**
68
+ * Get sync status from API
69
+ */
70
+ async getStatus(projectId: string): Promise<SyncStatus | null> {
71
+ if (!(await this.hasAuth())) {
72
+ return null
73
+ }
74
+
75
+ try {
76
+ return await syncClient.getStatus(projectId)
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Full sync: push local changes, then pull remote changes
84
+ */
85
+ async sync(projectId: string): Promise<SyncResult> {
86
+ // Check auth first
87
+ if (!(await this.hasAuth())) {
88
+ return { success: true, skipped: true, reason: 'no_auth' }
89
+ }
90
+
91
+ const result: SyncResult = { success: true, skipped: false }
92
+
93
+ // Push first
94
+ const pushResult = await this.push(projectId)
95
+ if (pushResult.success && !pushResult.skipped) {
96
+ result.pushed = {
97
+ count: pushResult.count || 0,
98
+ syncedAt: pushResult.syncedAt || new Date().toISOString(),
99
+ }
100
+ }
101
+
102
+ // Then pull
103
+ const pullResult = await this.pull(projectId)
104
+ if (pullResult.success && !pullResult.skipped) {
105
+ result.pulled = {
106
+ count: pullResult.count || 0,
107
+ syncedAt: pullResult.syncedAt || new Date().toISOString(),
108
+ }
109
+ }
110
+
111
+ // Determine overall success
112
+ if (!pushResult.success || !pullResult.success) {
113
+ result.success = false
114
+ result.error = pushResult.error || pullResult.error
115
+ }
116
+
117
+ return result
118
+ }
119
+
120
+ /**
121
+ * Push local pending events to the server
122
+ */
123
+ async push(projectId: string): Promise<PushResult> {
124
+ // Check auth first
125
+ if (!(await this.hasAuth())) {
126
+ return { success: true, skipped: true, reason: 'no_auth' }
127
+ }
128
+
129
+ try {
130
+ // Get pending events
131
+ const pending = await eventBus.getPending(projectId)
132
+
133
+ if (pending.length === 0) {
134
+ return { success: true, skipped: true, reason: 'no_pending' }
135
+ }
136
+
137
+ // Push to server
138
+ const result: SyncBatchResult = await syncClient.pushEvents(projectId, pending)
139
+
140
+ if (result.success) {
141
+ // Clear pending events on success
142
+ await eventBus.clearPending(projectId)
143
+ await eventBus.updateLastSync(projectId)
144
+
145
+ return {
146
+ success: true,
147
+ skipped: false,
148
+ count: result.processed,
149
+ syncedAt: result.syncedAt,
150
+ }
151
+ } else {
152
+ // Partial success - some events failed
153
+ const successCount = result.processed
154
+ const errorCount = result.errors.length
155
+ const errorMessages = result.errors.map((e) => e.error).join(', ')
156
+
157
+ return {
158
+ success: false,
159
+ skipped: false,
160
+ count: successCount,
161
+ syncedAt: result.syncedAt,
162
+ error: `${errorCount} events failed: ${errorMessages}`,
163
+ }
164
+ }
165
+ } catch (error) {
166
+ const message = error instanceof Error ? error.message : 'Unknown error'
167
+ return {
168
+ success: false,
169
+ skipped: false,
170
+ reason: 'error',
171
+ error: message,
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Pull remote changes from the server
178
+ */
179
+ async pull(projectId: string): Promise<PullResult> {
180
+ // Check auth first
181
+ if (!(await this.hasAuth())) {
182
+ return { success: true, skipped: true, reason: 'no_auth' }
183
+ }
184
+
185
+ try {
186
+ // Get last sync timestamp
187
+ const lastSync = await eventBus.getLastSync(projectId)
188
+ const since = lastSync?.timestamp
189
+
190
+ // Pull from server
191
+ const result: SyncPullResult = await syncClient.pullEvents(projectId, since)
192
+
193
+ if (result.events.length === 0) {
194
+ return {
195
+ success: true,
196
+ skipped: false,
197
+ count: 0,
198
+ applied: 0,
199
+ syncedAt: result.syncedAt,
200
+ }
201
+ }
202
+
203
+ // Apply pulled events to local storage
204
+ const applied = await this.applyPulledEvents(projectId, result.events)
205
+
206
+ // Update last sync timestamp
207
+ await eventBus.updateLastSync(projectId)
208
+
209
+ return {
210
+ success: true,
211
+ skipped: false,
212
+ count: result.events.length,
213
+ applied,
214
+ syncedAt: result.syncedAt,
215
+ }
216
+ } catch (error) {
217
+ const message = error instanceof Error ? error.message : 'Unknown error'
218
+ return {
219
+ success: false,
220
+ skipped: false,
221
+ reason: 'error',
222
+ error: message,
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Apply pulled events to local storage
229
+ * Returns number of events successfully applied
230
+ */
231
+ async applyPulledEvents(
232
+ projectId: string,
233
+ events: Array<{ type: string; path: string[]; data: unknown; timestamp: string }>
234
+ ): Promise<number> {
235
+ let applied = 0
236
+
237
+ for (const event of events) {
238
+ try {
239
+ await this.applyEvent(projectId, event)
240
+ applied++
241
+ } catch (error) {
242
+ // Log but continue with other events
243
+ console.error(`Failed to apply event ${event.type}:`, error)
244
+ }
245
+ }
246
+
247
+ return applied
248
+ }
249
+
250
+ /**
251
+ * Apply a single event to local storage
252
+ */
253
+ private async applyEvent(
254
+ projectId: string,
255
+ event: { type: string; path: string[]; data: unknown; timestamp: string }
256
+ ): Promise<void> {
257
+ const [entity, action] = event.type.split('.') as [string, string]
258
+ const data = event.data as Record<string, unknown>
259
+
260
+ switch (entity) {
261
+ case 'task':
262
+ await this.applyTaskEvent(projectId, action, data)
263
+ break
264
+ case 'idea':
265
+ await this.applyIdeaEvent(projectId, action, data)
266
+ break
267
+ case 'shipped':
268
+ await this.applyShippedEvent(projectId, action, data)
269
+ break
270
+ // Add more entity handlers as needed
271
+ }
272
+ }
273
+
274
+ private async applyTaskEvent(
275
+ projectId: string,
276
+ action: string,
277
+ data: Record<string, unknown>
278
+ ): Promise<void> {
279
+ switch (action) {
280
+ case 'started':
281
+ // Update state if this is a newer task
282
+ await stateStorage.update(projectId, (state) => {
283
+ if (!state.currentTask || (data.id as string) !== state.currentTask.id) {
284
+ return {
285
+ ...state,
286
+ currentTask: {
287
+ id: data.id as string,
288
+ description: data.description as string,
289
+ startedAt: data.startedAt as string,
290
+ sessionId: data.sessionId as string,
291
+ },
292
+ }
293
+ }
294
+ return state
295
+ })
296
+ break
297
+ case 'completed':
298
+ // Clear current task if it matches
299
+ await stateStorage.update(projectId, (state) => {
300
+ if (state.currentTask?.id === data.id) {
301
+ return { ...state, currentTask: null }
302
+ }
303
+ return state
304
+ })
305
+ break
306
+ case 'created':
307
+ // Add to queue
308
+ await queueStorage.addTask(projectId, {
309
+ description: data.description as string,
310
+ priority: (data.priority as Priority) || 'medium',
311
+ type: (data.type as TaskType) || 'feature',
312
+ section: 'backlog' as TaskSection,
313
+ })
314
+ break
315
+ }
316
+ }
317
+
318
+ private async applyIdeaEvent(
319
+ projectId: string,
320
+ action: string,
321
+ data: Record<string, unknown>
322
+ ): Promise<void> {
323
+ switch (action) {
324
+ case 'created':
325
+ await ideasStorage.addIdea(
326
+ projectId,
327
+ (data.title as string) || (data.text as string),
328
+ { priority: (data.priority as IdeaPriority) || 'medium' }
329
+ )
330
+ break
331
+ case 'archived':
332
+ await ideasStorage.update(projectId, (ideas) => ({
333
+ ...ideas,
334
+ ideas: ideas.ideas.map((idea) =>
335
+ idea.id === data.id ? { ...idea, status: 'archived' as const } : idea
336
+ ),
337
+ }))
338
+ break
339
+ }
340
+ }
341
+
342
+ private async applyShippedEvent(
343
+ projectId: string,
344
+ action: string,
345
+ data: Record<string, unknown>
346
+ ): Promise<void> {
347
+ if (action === 'created') {
348
+ await shippedStorage.addShipped(projectId, {
349
+ name: (data.name as string) || (data.title as string),
350
+ version: data.version as string,
351
+ description: data.description as string,
352
+ })
353
+ }
354
+ }
355
+ }
356
+
357
+ export const syncManager = new SyncManager()
358
+ export default syncManager
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Built for Claude - Ship fast, track progress, stay focused. Developer momentum tool for indie hackers.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ ---
2
+ allowed-tools: [Read, Write, Bash]
3
+ description: 'Manage Cloud Authentication'
4
+ timestamp-rule: 'GetTimestamp() for all timestamps'
5
+ ---
6
+
7
+ # /p:auth - Cloud Authentication
8
+
9
+ Manage authentication for prjct cloud sync.
10
+
11
+ ## Subcommands
12
+
13
+ | Command | Purpose |
14
+ |---------|---------|
15
+ | `/p:auth` | Show current auth status |
16
+ | `/p:auth login` | Authenticate with prjct cloud |
17
+ | `/p:auth logout` | Clear authentication |
18
+ | `/p:auth status` | Detailed auth status |
19
+
20
+ ## Context Variables
21
+ - `{authPath}`: `~/.prjct-cli/config/auth.json`
22
+ - `{apiUrl}`: API base URL (default: https://api.prjct.app)
23
+ - `{dashboardUrl}`: Web dashboard URL (https://app.prjct.app)
24
+
25
+ ---
26
+
27
+ ## /p:auth (default) - Show Status
28
+
29
+ ### Flow
30
+
31
+ 1. READ: `{authPath}`
32
+ 2. IF authenticated:
33
+ - Show email and API key prefix
34
+ 3. ELSE:
35
+ - Show "Not authenticated" message
36
+
37
+ ### Output (Authenticated)
38
+
39
+ ```
40
+ ☁️ Cloud Sync: Connected
41
+
42
+ Email: {email}
43
+ API Key: {apiKeyPrefix}...
44
+ Last auth: {lastAuth}
45
+
46
+ Sync enabled for all projects.
47
+ ```
48
+
49
+ ### Output (Not Authenticated)
50
+
51
+ ```
52
+ ☁️ Cloud Sync: Not connected
53
+
54
+ Run `/p:auth login` to enable cloud sync.
55
+
56
+ Benefits:
57
+ - Sync progress across devices
58
+ - Access from web dashboard
59
+ - Backup your project data
60
+ ```
61
+
62
+ ---
63
+
64
+ ## /p:auth login - Authenticate
65
+
66
+ ### Flow
67
+
68
+ 1. **Check existing auth**
69
+ READ: `{authPath}`
70
+ IF already authenticated:
71
+ ASK: "You're already logged in as {email}. Re-authenticate? (y/n)"
72
+ IF no: STOP
73
+
74
+ 2. **Open dashboard**
75
+ OUTPUT: "Opening prjct dashboard to get your API key..."
76
+ OPEN browser: `{dashboardUrl}/settings/api-keys`
77
+
78
+ 3. **Wait for API key**
79
+ OUTPUT instructions:
80
+ ```
81
+ 1. Log in to prjct.app (GitHub OAuth)
82
+ 2. Go to Settings → API Keys
83
+ 3. Click "Create New Key"
84
+ 4. Copy the key (starts with prjct_)
85
+ 5. Paste it below
86
+ ```
87
+
88
+ 4. **Get API key from user**
89
+ PROMPT: "Paste your API key: "
90
+ READ: `{apiKey}` from user input
91
+
92
+ 5. **Validate key**
93
+ - Check format starts with "prjct_"
94
+ - Test connection with GET /health
95
+ - Fetch user info with GET /auth/me
96
+
97
+ IF invalid:
98
+ OUTPUT: "Invalid API key. Please try again."
99
+ STOP
100
+
101
+ 6. **Save auth**
102
+ WRITE: `{authPath}`
103
+ ```json
104
+ {
105
+ "apiKey": "{apiKey}",
106
+ "apiUrl": "https://api.prjct.app",
107
+ "userId": "{userId}",
108
+ "email": "{email}",
109
+ "lastAuth": "{GetTimestamp()}"
110
+ }
111
+ ```
112
+
113
+ ### Output (Success)
114
+
115
+ ```
116
+ ✅ Authentication successful!
117
+
118
+ Logged in as: {email}
119
+ API Key: {apiKeyPrefix}...
120
+
121
+ Cloud sync is now enabled. Your projects will sync automatically
122
+ when you run /p:sync or /p:ship.
123
+ ```
124
+
125
+ ### Output (Failure)
126
+
127
+ ```
128
+ ❌ Authentication failed
129
+
130
+ {error}
131
+
132
+ Please check your API key and try again.
133
+ Get a new key at: {dashboardUrl}/settings/api-keys
134
+ ```
135
+
136
+ ---
137
+
138
+ ## /p:auth logout - Clear Auth
139
+
140
+ ### Flow
141
+
142
+ 1. READ: `{authPath}`
143
+ IF not authenticated:
144
+ OUTPUT: "Not logged in. Nothing to do."
145
+ STOP
146
+
147
+ 2. ASK: "Are you sure you want to log out? (y/n)"
148
+ IF no: STOP
149
+
150
+ 3. DELETE or CLEAR: `{authPath}`
151
+
152
+ ### Output
153
+
154
+ ```
155
+ ✅ Logged out successfully
156
+
157
+ Cloud sync is now disabled.
158
+ Run `/p:auth login` to re-enable.
159
+ ```
160
+
161
+ ---
162
+
163
+ ## /p:auth status - Detailed Status
164
+
165
+ ### Flow
166
+
167
+ 1. READ: `{authPath}`
168
+ 2. IF authenticated:
169
+ - Test connection
170
+ - Show detailed status
171
+ 3. ELSE:
172
+ - Show not connected message
173
+
174
+ ### Output (Connected)
175
+
176
+ ```
177
+ ☁️ Cloud Authentication Status
178
+
179
+ Connection: ✓ Connected
180
+ Email: {email}
181
+ User ID: {userId}
182
+ API Key: {apiKeyPrefix}...
183
+ API URL: {apiUrl}
184
+ Last Auth: {lastAuth}
185
+
186
+ API Status: ✓ Reachable
187
+ ```
188
+
189
+ ### Output (Connection Error)
190
+
191
+ ```
192
+ ☁️ Cloud Authentication Status
193
+
194
+ Connection: ⚠️ Error
195
+ Email: {email}
196
+ API Key: {apiKeyPrefix}...
197
+ API URL: {apiUrl}
198
+
199
+ Error: {connectionError}
200
+
201
+ Try `/p:auth login` to re-authenticate.
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Error Handling
207
+
208
+ | Error | Response |
209
+ |-------|----------|
210
+ | Invalid key format | "API key must start with prjct_" |
211
+ | Key rejected by API | "Invalid or expired API key" |
212
+ | Network error | "Cannot connect to {apiUrl}. Check internet." |
213
+ | Already logged in | Offer to re-authenticate |
214
+
215
+ ---
216
+
217
+ ## Auth File Structure
218
+
219
+ Location: `~/.prjct-cli/config/auth.json`
220
+
221
+ ```json
222
+ {
223
+ "apiKey": "prjct_live_xxxxxxxxxxxxxxxxxxxx",
224
+ "apiUrl": "https://api.prjct.app",
225
+ "userId": "uuid-from-server",
226
+ "email": "user@example.com",
227
+ "lastAuth": "2024-01-15T10:00:00.000Z"
228
+ }
229
+ ```
230
+
231
+ **Security Notes:**
232
+ - API key is stored in plain text (like git credentials)
233
+ - File permissions should be 600 (user read/write only)
234
+ - Never commit this file to version control
@@ -54,6 +54,42 @@ Git Analysis → Storage (JSON) → Context (MD) → Project Metadata
54
54
 
55
55
  ---
56
56
 
57
+ ## Step 0: Migration Check (Legacy Projects)
58
+
59
+ CHECK: Does `.prjct/prjct.config.json` exist?
60
+
61
+ IF file exists:
62
+ READ: `.prjct/prjct.config.json`
63
+ CHECK: Does `projectId` exist and is it a valid UUID?
64
+
65
+ IF projectId is missing OR not a UUID:
66
+ MIGRATE to UUID:
67
+ 1. Generate new UUID: `{newProjectId}`
68
+ 2. Create global structure: `~/.prjct-cli/projects/{newProjectId}/`
69
+ 3. Create subdirectories: storage/, context/, agents/, memory/, analysis/
70
+ 4. IF legacy data exists in `.prjct/`:
71
+ - Migrate core/now.md → storage/state.json
72
+ - Migrate planning/ideas.md → storage/ideas.json
73
+ - Migrate progress/shipped.md → storage/shipped.json
74
+ 5. Update `.prjct/prjct.config.json` with new `projectId`
75
+ OUTPUT: "🔄 Migrated to UUID format: {newProjectId}"
76
+
77
+ IF file not found:
78
+ CHECK: Does `.prjct/` directory exist? (legacy project without config)
79
+
80
+ IF `.prjct/` exists:
81
+ MIGRATE:
82
+ 1. Generate new UUID: `{newProjectId}`
83
+ 2. Create `.prjct/prjct.config.json` with `projectId`
84
+ 3. Create global structure
85
+ 4. Migrate legacy data
86
+ OUTPUT: "🔄 Migrated legacy project to UUID: {newProjectId}"
87
+ ELSE:
88
+ OUTPUT: "No prjct project. Run /p:init first."
89
+ STOP
90
+
91
+ ---
92
+
57
93
  ## Step 1: Read Config
58
94
 
59
95
  READ: `.prjct/prjct.config.json`
@@ -409,6 +445,52 @@ APPEND to: `{globalPath}/memory/events.jsonl`
409
445
 
410
446
  ---
411
447
 
448
+ ## Step 9: Backend Sync (Cloud)
449
+
450
+ Sync with prjct API if authenticated.
451
+
452
+ ### 9.1 Check Authentication
453
+
454
+ READ: `~/.prjct-cli/config/auth.json`
455
+
456
+ IF no auth OR no apiKey:
457
+ SET: `{cloudSync}` = false
458
+ OUTPUT TIP: "💡 Run `prjct auth` to enable cloud sync"
459
+ CONTINUE to output (skip 9.2, 9.3)
460
+
461
+ ELSE:
462
+ SET: `{cloudSync}` = true
463
+
464
+ ### 9.2 Push Pending Events
465
+
466
+ READ: `{globalPath}/sync/pending.json`
467
+ COUNT: `{pendingCount}` events
468
+
469
+ IF pendingCount > 0:
470
+ CALL syncManager.push(projectId)
471
+
472
+ IF success:
473
+ SET: `{pushedCount}` = result.count
474
+ OUTPUT: "☁️ Pushed {pushedCount} events to cloud"
475
+ ELSE:
476
+ OUTPUT: "⚠️ Cloud sync failed: {error}. Events queued for retry."
477
+ SET: `{syncError}` = error
478
+ ELSE:
479
+ SET: `{pushedCount}` = 0
480
+
481
+ ### 9.3 Pull Updates (if push succeeded)
482
+
483
+ IF cloudSync AND no syncError:
484
+ CALL syncManager.pull(projectId)
485
+
486
+ IF success AND result.count > 0:
487
+ SET: `{pulledCount}` = result.count
488
+ OUTPUT: "📥 Pulled {pulledCount} updates from cloud"
489
+ ELSE:
490
+ SET: `{pulledCount}` = 0
491
+
492
+ ---
493
+
412
494
  ## Output
413
495
 
414
496
  ```
@@ -436,6 +518,15 @@ APPEND to: `{globalPath}/memory/events.jsonl`
436
518
  ├── Workflow: prjct-workflow, prjct-planner, prjct-shipper
437
519
  └── Domain: {domainAgents.join(', ') || 'none'}
438
520
 
521
+ {IF cloudSync}
522
+ ☁️ Cloud Sync
523
+ ├── Pushed: {pushedCount} events
524
+ ├── Pulled: {pulledCount} updates
525
+ └── Status: {syncError ? "⚠️ " + syncError : "✓ Synced"}
526
+ {ELSE}
527
+ 💡 Cloud sync disabled. Run `prjct auth` to enable.
528
+ {ENDIF}
529
+
439
530
  {IF hasUncommittedChanges}
440
531
  ⚠️ You have uncommitted changes
441
532