promptcase 1.0.4

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,432 @@
1
+ /**
2
+ * Daemon service for background sync
3
+ */
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import { APIService } from './api.js';
8
+ import { getConfig } from '../lib/config.js';
9
+ import { ClaudeCaptureService } from './claude-capture.js';
10
+ import { scriptPath } from '../lib/path.js';
11
+ import { DEFAULT_API_URL, MAX_PROMPTS_PER_SYNC } from '../lib/constants.js';
12
+ export class DaemonService {
13
+ apiService;
14
+ captureService;
15
+ configService;
16
+ syncInterval = null;
17
+ status;
18
+ pidFile;
19
+ constructor(apiUrl = DEFAULT_API_URL) {
20
+ this.apiService = new APIService(apiUrl);
21
+ this.captureService = new ClaudeCaptureService();
22
+ this.configService = getConfig();
23
+ this.pidFile = this.getPidFilePath();
24
+ this.status = {
25
+ lastSyncAt: null,
26
+ promptsSynced: 0,
27
+ errors: [],
28
+ isRunning: false,
29
+ };
30
+ }
31
+ /**
32
+ * Get PID file path for cross-platform support
33
+ */
34
+ getPidFilePath() {
35
+ const isWindows = process.platform === 'win32';
36
+ const configDir = path.join(os.homedir(), '.promptcase');
37
+ if (isWindows) {
38
+ return path.join(configDir, 'daemon.lock');
39
+ }
40
+ return path.join(configDir, 'daemon.pid');
41
+ }
42
+ /**
43
+ * Check if daemon is already running
44
+ */
45
+ async isRunning() {
46
+ if (!fs.existsSync(this.pidFile)) {
47
+ return false;
48
+ }
49
+ try {
50
+ const pid = parseInt(fs.readFileSync(this.pidFile, 'utf-8').trim(), 10);
51
+ // On Unix, check if process exists
52
+ if (process.platform !== 'win32') {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch {
58
+ // Process doesn't exist
59
+ await this.cleanupPidFile();
60
+ return false;
61
+ }
62
+ }
63
+ // On Windows, just check if PID file exists
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Write PID file
72
+ */
73
+ async writePidFile() {
74
+ const configDir = path.dirname(this.pidFile);
75
+ if (!fs.existsSync(configDir)) {
76
+ fs.mkdirSync(configDir, { recursive: true });
77
+ }
78
+ fs.writeFileSync(this.pidFile, process.pid.toString());
79
+ }
80
+ /**
81
+ * Clean up PID file
82
+ */
83
+ async cleanupPidFile() {
84
+ if (fs.existsSync(this.pidFile)) {
85
+ fs.unlinkSync(this.pidFile);
86
+ }
87
+ }
88
+ /**
89
+ * Initialize with stored credentials
90
+ */
91
+ async initialize() {
92
+ const credentials = await this.configService.getCredentials();
93
+ if (!credentials) {
94
+ return false;
95
+ }
96
+ this.apiService.setTokens(credentials.accessToken, credentials.refreshToken);
97
+ // Restore the lifetime promptsSynced counter from the previous run.
98
+ // This is important for `promptcase sync` which creates a fresh
99
+ // DaemonService instance each invocation — without restore, every
100
+ // foreground sync would show the count for just that one call instead
101
+ // of the cumulative lifetime total.
102
+ const stored = await this.configService.getDaemonStatus();
103
+ if (stored && typeof stored.promptsSynced === 'number') {
104
+ this.status.promptsSynced = stored.promptsSynced;
105
+ }
106
+ return true;
107
+ }
108
+ /**
109
+ * Save refreshed tokens back to config.
110
+ *
111
+ * The server refresh endpoint returns new `expires_in` (in seconds). We
112
+ * honour that value instead of hardcoding 180 days so if the server
113
+ * ever rotates the TTL, the CLI tracks it correctly.
114
+ */
115
+ async saveRefreshedTokens() {
116
+ const credentials = await this.configService.getCredentials();
117
+ if (!credentials)
118
+ return;
119
+ const accessToken = this.apiService.getAccessToken();
120
+ const refreshToken = this.apiService.getRefreshToken();
121
+ if (accessToken && refreshToken) {
122
+ // Use existing expiresAt as fallback; we don't know the server's new
123
+ // expires_in from outside the refresh path, so the safest is to keep
124
+ // the existing window unless we have a fresh signal. (The TTL on
125
+ // tokens in `refreshAccessToken`'s response is 180d server-side.)
126
+ await this.configService.setCredentials({
127
+ accessToken,
128
+ refreshToken,
129
+ expiresAt: credentials.expiresAt,
130
+ createdAt: credentials.createdAt,
131
+ });
132
+ }
133
+ }
134
+ /**
135
+ * Start the daemon (foreground process)
136
+ */
137
+ async start() {
138
+ // Initialize with credentials
139
+ if (!await this.initialize()) {
140
+ console.error('No credentials found. Run "promptcase init" first.');
141
+ return false;
142
+ }
143
+ // Write PID file
144
+ await this.writePidFile();
145
+ // Start sync loop
146
+ const syncInterval = await this.configService.getSyncInterval();
147
+ this.status.isRunning = true;
148
+ // Restore the lifetime promptsSynced counter from the previous run so
149
+ // the count stays cumulative across daemon restarts. The `status`
150
+ // command reads this from conf, so without restoring it, every fresh
151
+ // daemon process would report "Total synced: 0" until the first sync.
152
+ const stored = await this.configService.getDaemonStatus();
153
+ if (stored && typeof stored.promptsSynced === 'number') {
154
+ this.status.promptsSynced = stored.promptsSynced;
155
+ }
156
+ // Save daemon status
157
+ await this.configService.setDaemonStatus({
158
+ isRunning: true,
159
+ pid: process.pid,
160
+ startedAt: new Date(),
161
+ lastSyncAt: null,
162
+ nextSyncAt: new Date(Date.now() + syncInterval),
163
+ promptsSynced: this.status.promptsSynced,
164
+ });
165
+ console.log(`\n🟢 Daemon started (PID: ${process.pid})`);
166
+ console.log(` Syncing every ${syncInterval / 1000} seconds`);
167
+ console.log(` Logs: ${this.getPidFilePath().replace(/\/[^\/]+$/, '/daemon.log')}`);
168
+ console.log(`\n Press Ctrl+C to stop the daemon\n`);
169
+ // Initial sync
170
+ await this.sync();
171
+ // Schedule periodic syncs. The interval timers MUST stay alive (no
172
+ // `.unref()`) because they are the only thing keeping the Node event
173
+ // loop busy once `start()` returns its `new Promise(() => {})`. Adding
174
+ // `.unref()` here caused the daemon to silently exit after the first
175
+ // sync (regression from v1.0.2's working behavior). KeepAlive=true in
176
+ // the LaunchAgent then respawns it, but each respawn dies the same way
177
+ // — visible to the user as "Daemon: 🔴 Not running".
178
+ this.syncInterval = setInterval(async () => {
179
+ await this.sync();
180
+ }, syncInterval);
181
+ // Save refreshed tokens every hour.
182
+ setInterval(async () => {
183
+ await this.saveRefreshedTokens();
184
+ }, 60 * 60 * 1000);
185
+ // Handle graceful shutdown
186
+ process.on('SIGINT', async () => {
187
+ console.log('\n\n🛑 Stopping daemon...');
188
+ await this.stop();
189
+ process.exit(0);
190
+ });
191
+ process.on('SIGTERM', async () => {
192
+ console.log('\n\n🛑 Stopping daemon...');
193
+ await this.stop();
194
+ process.exit(0);
195
+ });
196
+ // Keep the process alive
197
+ return new Promise(() => {
198
+ // Never resolves - keeps daemon running
199
+ });
200
+ }
201
+ /**
202
+ * Stop the daemon
203
+ */
204
+ async stop() {
205
+ if (this.syncInterval) {
206
+ clearInterval(this.syncInterval);
207
+ this.syncInterval = null;
208
+ }
209
+ await this.cleanupPidFile();
210
+ this.status.isRunning = false;
211
+ await this.configService.setDaemonStatus({
212
+ isRunning: false,
213
+ pid: undefined,
214
+ startedAt: null,
215
+ lastSyncAt: this.status.lastSyncAt,
216
+ nextSyncAt: null,
217
+ // Preserve the lifetime synced count so the next daemon start picks it up
218
+ promptsSynced: this.status.promptsSynced,
219
+ });
220
+ return true;
221
+ }
222
+ /**
223
+ * Sync prompts from Claude to backend
224
+ */
225
+ async sync() {
226
+ if (!this.apiService.isAuthenticated()) {
227
+ this.status.errors.push('Not authenticated');
228
+ return this.status;
229
+ }
230
+ try {
231
+ // Get prompts from Claude. Use a timestamp cursor to skip prompts older
232
+ // than the last successful sync (server dedups, but skipping locally
233
+ // saves bandwidth on subsequent syncs).
234
+ const cursorIso = await this.configService.getLastSyncCursor();
235
+ const since = cursorIso ? new Date(cursorIso) : undefined;
236
+ const prompts = await this.captureService.getAllPrompts(since, MAX_PROMPTS_PER_SYNC);
237
+ if (prompts.length === 0) {
238
+ this.status.lastSyncAt = new Date();
239
+ await this.configService.setDaemonStatus({
240
+ isRunning: true,
241
+ pid: process.pid,
242
+ startedAt: new Date(),
243
+ lastSyncAt: this.status.lastSyncAt,
244
+ nextSyncAt: new Date(Date.now() + (await this.configService.getSyncInterval())),
245
+ promptsSynced: this.status.promptsSynced,
246
+ });
247
+ return this.status;
248
+ }
249
+ // Prepare prompts for API
250
+ const apiPrompts = prompts.map((p) => ({
251
+ content: p.content,
252
+ title: p.title,
253
+ source_type: 'claude_code',
254
+ captured_at: p.timestamp.toISOString(),
255
+ content_hash: p.hash,
256
+ app_context: {
257
+ projectPath: p.projectPath,
258
+ sessionId: p.sessionId,
259
+ },
260
+ }));
261
+ // Sync to backend
262
+ const result = await this.apiService.syncPrompts(apiPrompts);
263
+ this.status.lastSyncAt = new Date();
264
+ this.status.promptsSynced += result.synced;
265
+ // Persist the cursor at the newest prompt we ATTEMPTED to sync (not
266
+ // just newly-synced ones). The server's content_hash dedup means we'll
267
+ // see `result.synced: 0` on subsequent runs even though the prompts
268
+ // are already saved — that's normal. We want the cursor to advance
269
+ // so the next run only fetches prompts newer than the newest one we've
270
+ // seen, regardless of whether they were already on the server.
271
+ if (prompts.length > 0) {
272
+ const newest = prompts[0].timestamp;
273
+ await this.configService.setLastSyncCursor(newest.toISOString());
274
+ }
275
+ // Update daemon status with new sync time. Include promptsSynced so the
276
+ // `status` command (which reads from conf) shows the cumulative count
277
+ // across daemon restarts.
278
+ const syncInterval = await this.configService.getSyncInterval();
279
+ await this.configService.setDaemonStatus({
280
+ isRunning: true,
281
+ pid: process.pid,
282
+ startedAt: new Date(),
283
+ lastSyncAt: this.status.lastSyncAt,
284
+ nextSyncAt: new Date(Date.now() + syncInterval),
285
+ promptsSynced: this.status.promptsSynced,
286
+ });
287
+ console.log(`[${new Date().toLocaleTimeString()}] ✅ Synced ${result.synced} prompts (total this session: ${this.status.promptsSynced})`);
288
+ return this.status;
289
+ }
290
+ catch (error) {
291
+ const errorMsg = `Sync error: ${error.message}`;
292
+ this.status.errors.push(errorMsg);
293
+ console.error(`[${new Date().toLocaleTimeString()}] ❌ ${errorMsg}`);
294
+ return this.status;
295
+ }
296
+ }
297
+ /**
298
+ * Get current status
299
+ */
300
+ getStatus() {
301
+ return { ...this.status };
302
+ }
303
+ /**
304
+ * Setup process manager for auto-start on boot
305
+ */
306
+ async setupAutoStart() {
307
+ try {
308
+ if (process.platform === 'win32') {
309
+ return this.setupWindowsService();
310
+ }
311
+ else if (process.platform === 'darwin') {
312
+ return this.setupMacOSLaunchd();
313
+ }
314
+ else {
315
+ return this.setupLinuxSystemd();
316
+ }
317
+ }
318
+ catch (error) {
319
+ console.error('Failed to setup auto-start:', error.message);
320
+ return false;
321
+ }
322
+ }
323
+ /**
324
+ * Get auto-start script command
325
+ * Uses 'start' subcommand since daemon command is removed
326
+ */
327
+ getAutoStartCommand() {
328
+ return `${process.execPath} ${scriptPath()} start`;
329
+ }
330
+ /**
331
+ * Setup macOS LaunchAgent for auto-start
332
+ */
333
+ async setupMacOSLaunchd() {
334
+ const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
335
+ const plistPath = path.join(launchAgentsDir, 'app.promptcase.daemon.plist');
336
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
337
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
338
+ <plist version="1.0">
339
+ <dict>
340
+ <key>Label</key>
341
+ <string>app.promptcase.daemon</string>
342
+ <key>ProgramArguments</key>
343
+ <array>
344
+ <string>${process.execPath}</string>
345
+ <string>${scriptPath()}</string>
346
+ <string>start</string>
347
+ </array>
348
+ <key>RunAtLoad</key>
349
+ <true/>
350
+ <key>KeepAlive</key>
351
+ <true/>
352
+ <key>ProcessType</key>
353
+ <string>Background</string>
354
+ <key>StandardOutPath</key>
355
+ <string>${path.join(os.homedir(), '.promptcase', 'daemon.log')}</string>
356
+ <key>StandardErrorPath</key>
357
+ <string>${path.join(os.homedir(), '.promptcase', 'daemon.error.log')}</string>
358
+ </dict>
359
+ </plist>`;
360
+ try {
361
+ if (!fs.existsSync(launchAgentsDir)) {
362
+ fs.mkdirSync(launchAgentsDir, { recursive: true });
363
+ }
364
+ fs.writeFileSync(plistPath, plistContent);
365
+ console.log(`✅ LaunchAgent installed at ${plistPath}`);
366
+ return true;
367
+ }
368
+ catch (error) {
369
+ console.error('Failed to setup LaunchAgent:', error.message);
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Setup Linux systemd service for auto-start
375
+ */
376
+ async setupLinuxSystemd() {
377
+ // Use user-level systemd service instead of system-level (no sudo needed)
378
+ const userServiceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
379
+ const servicePath = path.join(userServiceDir, 'promptcase.service');
380
+ const serviceContent = `[Unit]
381
+ Description=PromptCase CLI Daemon
382
+ After=network.target
383
+
384
+ [Service]
385
+ Type=simple
386
+ ExecStart=${this.getAutoStartCommand()}
387
+ Restart=on-failure
388
+ RestartSec=10
389
+ StandardOutput=append:${path.join(os.homedir(), '.promptcase', 'daemon.log')}
390
+ StandardError=append:${path.join(os.homedir(), '.promptcase', 'daemon.error.log')}
391
+
392
+ [Install]
393
+ WantedBy=default.target`;
394
+ try {
395
+ if (!fs.existsSync(userServiceDir)) {
396
+ fs.mkdirSync(userServiceDir, { recursive: true });
397
+ }
398
+ fs.writeFileSync(servicePath, serviceContent);
399
+ console.log(`✅ User systemd service installed at ${servicePath}`);
400
+ console.log(' To enable auto-start, run:');
401
+ console.log(' systemctl --user enable promptcase');
402
+ console.log(' systemctl --user start promptcase');
403
+ return true;
404
+ }
405
+ catch (error) {
406
+ console.error('Failed to setup systemd service:', error.message);
407
+ return false;
408
+ }
409
+ }
410
+ /**
411
+ * Setup Windows service for auto-start
412
+ */
413
+ async setupWindowsService() {
414
+ const startupPath = path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
415
+ const batchPath = path.join(startupPath, 'promptcase.bat');
416
+ const batchContent = `@echo off
417
+ "${this.getAutoStartCommand()}"`;
418
+ try {
419
+ if (!fs.existsSync(startupPath)) {
420
+ fs.mkdirSync(startupPath, { recursive: true });
421
+ }
422
+ fs.writeFileSync(batchPath, batchContent);
423
+ console.log(`✅ Startup shortcut installed at ${batchPath}`);
424
+ return true;
425
+ }
426
+ catch (error) {
427
+ console.error('Failed to setup Windows startup:', error.message);
428
+ return false;
429
+ }
430
+ }
431
+ }
432
+ //# sourceMappingURL=daemon.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Shared types for PromptCase CLI
3
+ */
4
+ // Supported source types — kept in sync with the `prompts.source_type`
5
+ // check constraint in the database schema (migration 001).
6
+ export const SourceType = {
7
+ CLAUDE_CODE: 'claude_code',
8
+ CLAUDE_AI: 'claude_ai',
9
+ GEMINI: 'gemini',
10
+ VS_CODE: 'vs_code',
11
+ CLAUDE_DESKTOP: 'claude_desktop',
12
+ };
13
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "promptcase",
3
+ "version": "1.0.4",
4
+ "description": "CLI daemon to capture and sync AI prompts to PromptCase web app",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "promptcase": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/index.js",
12
+ "dev": "tsx src/index.ts",
13
+ "test": "jest",
14
+ "lint": "eslint src --ext .ts",
15
+ "lint:fix": "eslint src --ext .ts --fix"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "prompts",
20
+ "capture",
21
+ "sync",
22
+ "daemon"
23
+ ],
24
+ "author": "PromptCase",
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "dependencies": {
28
+ "commander": "^11.1.0",
29
+ "conf": "^12.0.0",
30
+ "got": "^14.2.1",
31
+ "inquirer": "^9.2.16",
32
+ "promptcase": "file:"
33
+ },
34
+ "devDependencies": {
35
+ "@types/inquirer": "^9.0.10",
36
+ "@types/node": "^20.10.0",
37
+ "tsx": "^4.6.1",
38
+ "typescript": "^5.3.2"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
42
+ },
43
+ "types": "./dist/index.d.ts"
44
+ }