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.
- package/README.md +67 -0
- package/dist/commands/init.js +319 -0
- package/dist/commands/logout.js +57 -0
- package/dist/commands/show.js +59 -0
- package/dist/commands/start.js +34 -0
- package/dist/commands/status.js +126 -0
- package/dist/commands/stop.js +43 -0
- package/dist/commands/sync.js +117 -0
- package/dist/index.js +94 -0
- package/dist/lib/config.js +132 -0
- package/dist/lib/constants.js +18 -0
- package/dist/lib/path.js +26 -0
- package/dist/services/api.js +211 -0
- package/dist/services/claude-capture.js +368 -0
- package/dist/services/daemon.js +432 -0
- package/dist/types/index.js +13 -0
- package/package.json +44 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop command - Stop daemon for this OS session
|
|
3
|
+
*
|
|
4
|
+
* Behavior:
|
|
5
|
+
* - Stops the daemon process if running
|
|
6
|
+
* - Keeps auto-start configuration intact so daemon restarts on next boot
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { DaemonService } from '../services/daemon.js';
|
|
10
|
+
import { DEFAULT_API_URL } from '../lib/constants.js';
|
|
11
|
+
export function createStopCommand(apiUrl = DEFAULT_API_URL) {
|
|
12
|
+
const command = new Command('stop');
|
|
13
|
+
command
|
|
14
|
+
.description('Stop the daemon for this OS session (auto-start on next boot preserved)')
|
|
15
|
+
.action(async () => {
|
|
16
|
+
const daemon = new DaemonService(apiUrl);
|
|
17
|
+
console.log('\nš Stopping PromptCase Daemon\n');
|
|
18
|
+
const isRunning = await daemon.isRunning();
|
|
19
|
+
if (!isRunning) {
|
|
20
|
+
console.log(' Daemon is not running.\n');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const success = await daemon.stop();
|
|
24
|
+
if (success) {
|
|
25
|
+
console.log(' ā
Daemon stopped successfully');
|
|
26
|
+
console.log(' ā¹ļø Auto-start is still configured. Daemon will restart on next boot.');
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.log(' ā ļø Could not stop daemon gracefully. Kill the process manually:');
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
console.log(' taskkill /F /IM node.exe');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log(' pkill -f "promptcase start"');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
console.log('\n Auto-start preserved. To completely remove auto-start, run:');
|
|
38
|
+
console.log(' promptcase logout');
|
|
39
|
+
console.log(' Or manually remove the LaunchAgent / systemd service / .bat file.\n');
|
|
40
|
+
});
|
|
41
|
+
return command;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=stop.js.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync command - Force a sync without invalidating login
|
|
3
|
+
*
|
|
4
|
+
* Used when daemon gets stuck or sync isn't working.
|
|
5
|
+
* Performs diagnostics and forces a refresh of the sync process.
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { APIService } from '../services/api.js';
|
|
10
|
+
import { DaemonService } from '../services/daemon.js';
|
|
11
|
+
import { ClaudeCaptureService } from '../services/claude-capture.js';
|
|
12
|
+
import { getConfig } from '../lib/config.js';
|
|
13
|
+
import { scriptPath } from '../lib/path.js';
|
|
14
|
+
import { DEFAULT_API_URL } from '../lib/constants.js';
|
|
15
|
+
export function createSyncCommand(apiUrl = DEFAULT_API_URL) {
|
|
16
|
+
const command = new Command('sync');
|
|
17
|
+
command
|
|
18
|
+
.description('Force a sync and run diagnostics (does not invalidate login)')
|
|
19
|
+
.action(async () => {
|
|
20
|
+
console.log('\nš Force Sync & Diagnostics\n');
|
|
21
|
+
const config = getConfig();
|
|
22
|
+
const api = new APIService(apiUrl);
|
|
23
|
+
const daemon = new DaemonService(apiUrl);
|
|
24
|
+
// 1. Authentication check
|
|
25
|
+
const isAuthenticated = await config.isAuthenticated();
|
|
26
|
+
if (!isAuthenticated) {
|
|
27
|
+
console.log(' ā Not authenticated. Run "promptcase init" first.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const credentials = await config.getCredentials();
|
|
31
|
+
if (!credentials) {
|
|
32
|
+
console.log(' ā No credentials found. Run "promptcase init" first.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
api.setTokens(credentials.accessToken, credentials.refreshToken);
|
|
36
|
+
// 2. Verify token is still valid
|
|
37
|
+
console.log(' š” Checking API connection...');
|
|
38
|
+
const userInfo = await api.verifyToken();
|
|
39
|
+
if (!userInfo) {
|
|
40
|
+
console.log(' ā Token is invalid or expired. Run "promptcase init" to re-authenticate.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
console.log(` ā
API connection successful (User: ${userInfo.userId})`);
|
|
44
|
+
// 3. Check daemon status
|
|
45
|
+
console.log('\n š Daemon status:');
|
|
46
|
+
const isRunning = await daemon.isRunning();
|
|
47
|
+
if (isRunning) {
|
|
48
|
+
const daemonStatus = await config.getDaemonStatus();
|
|
49
|
+
console.log(` Status: Running (PID ${daemonStatus.pid})`);
|
|
50
|
+
console.log(` Last sync: ${daemonStatus.lastSyncAt ? new Date(daemonStatus.lastSyncAt).toLocaleString() : 'Never'}`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log(' Status: Not running');
|
|
54
|
+
console.log(' ā ļø Daemon is not running - starting a sync anyway');
|
|
55
|
+
}
|
|
56
|
+
// 4. Check Claude installation
|
|
57
|
+
console.log('\n š¦ Claude Code check:');
|
|
58
|
+
const capture = new ClaudeCaptureService();
|
|
59
|
+
const sessionCount = await capture.countSessionFiles();
|
|
60
|
+
if (sessionCount > 0) {
|
|
61
|
+
console.log(` ā
Claude installed with ${sessionCount} session file(s)`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(' ā ļø Claude Code not installed (~/.claude not found)');
|
|
65
|
+
console.log(' Nothing to capture until Claude Code is installed and used.');
|
|
66
|
+
}
|
|
67
|
+
// 5. Perform sync
|
|
68
|
+
console.log('\n š Triggering sync...');
|
|
69
|
+
const initialized = await daemon.initialize();
|
|
70
|
+
if (!initialized) {
|
|
71
|
+
console.log(' ā Failed to initialize daemon');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const status = await daemon.sync();
|
|
75
|
+
console.log('\n š Results:');
|
|
76
|
+
if (status.lastSyncAt) {
|
|
77
|
+
console.log(` Last sync: ${status.lastSyncAt.toLocaleString()}`);
|
|
78
|
+
console.log(` Total synced (lifetime): ${status.promptsSynced} prompts`);
|
|
79
|
+
if (status.errors.length > 0) {
|
|
80
|
+
console.log(` Errors (${status.errors.length}):`);
|
|
81
|
+
status.errors.slice(-3).forEach((err) => console.log(` - ${err}`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 6. If daemon is not running, start it
|
|
85
|
+
if (!isRunning) {
|
|
86
|
+
console.log('\n š Daemon was not running. Starting now in background...');
|
|
87
|
+
const child = spawn(process.execPath, [scriptPath(), 'start'], {
|
|
88
|
+
detached: true,
|
|
89
|
+
stdio: 'ignore',
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
});
|
|
92
|
+
child.unref();
|
|
93
|
+
// Wait then verify
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
95
|
+
const nowRunning = await daemon.isRunning();
|
|
96
|
+
if (nowRunning) {
|
|
97
|
+
console.log(' ā
Daemon started successfully');
|
|
98
|
+
console.log(' ā¹ļø Auto-start is configured - daemon will resume on next boot');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(' ā ļø Daemon may not have started. Check with "promptcase status"');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 7. Refresh token if needed
|
|
105
|
+
const expiresAt = new Date(credentials.expiresAt);
|
|
106
|
+
const daysLeft = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
107
|
+
if (daysLeft < 7) {
|
|
108
|
+
console.log(`\n ā ļø Token expires in ${daysLeft} days. Consider running "promptcase init" to refresh.`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log(`\n ā
Login still valid for ${daysLeft} more days`);
|
|
112
|
+
}
|
|
113
|
+
console.log('');
|
|
114
|
+
});
|
|
115
|
+
return command;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=sync.js.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PromptCase CLI - Main Entry Point
|
|
4
|
+
*
|
|
5
|
+
* A daemon to capture and sync AI prompts from Claude Code
|
|
6
|
+
* to the PromptCase web application.
|
|
7
|
+
*/
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
import { createInitCommand, handleNonInteractiveToken } from './commands/init.js';
|
|
11
|
+
import { createStartCommand } from './commands/start.js';
|
|
12
|
+
import { createStopCommand } from './commands/stop.js';
|
|
13
|
+
import { createSyncCommand } from './commands/sync.js';
|
|
14
|
+
import { createShowCommand } from './commands/show.js';
|
|
15
|
+
import { createStatusCommand } from './commands/status.js';
|
|
16
|
+
import { createLogoutCommand } from './commands/logout.js';
|
|
17
|
+
import { DEFAULT_API_URL } from './lib/constants.js';
|
|
18
|
+
// Default API URL (same as deployed web app)
|
|
19
|
+
// Read version from package.json so it always matches the published version
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const pkg = require('../package.json');
|
|
22
|
+
const program = new Command();
|
|
23
|
+
program
|
|
24
|
+
.name('promptcase')
|
|
25
|
+
.description('CLI daemon to capture and sync AI prompts from Claude Code to PromptCase')
|
|
26
|
+
.version(pkg.version)
|
|
27
|
+
.option('-a, --api-url <url>', 'API URL', DEFAULT_API_URL);
|
|
28
|
+
// Check if init command is called with piped input (no TTY and no options/flags)
|
|
29
|
+
const isPipedInit = process.argv[2] === 'init' &&
|
|
30
|
+
process.argv.length === 3 &&
|
|
31
|
+
!process.stdin.isTTY;
|
|
32
|
+
if (isPipedInit) {
|
|
33
|
+
// Read piped JSON from stdin
|
|
34
|
+
let pipedData = '';
|
|
35
|
+
let handled = false;
|
|
36
|
+
process.stdin.on('data', (chunk) => {
|
|
37
|
+
pipedData += chunk;
|
|
38
|
+
});
|
|
39
|
+
process.stdin.on('end', () => {
|
|
40
|
+
if (!handled && pipedData) {
|
|
41
|
+
handled = true;
|
|
42
|
+
handleNonInteractiveToken(pipedData.trim(), DEFAULT_API_URL);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// Failsafe timeout in case stdin never ends
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
if (!handled) {
|
|
48
|
+
handled = true;
|
|
49
|
+
// No piped data within 200ms - use commander
|
|
50
|
+
runCommander();
|
|
51
|
+
}
|
|
52
|
+
}, 200);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
runCommander();
|
|
56
|
+
}
|
|
57
|
+
function runCommander() {
|
|
58
|
+
// Register commands
|
|
59
|
+
program.addCommand(createInitCommand());
|
|
60
|
+
// `start` is an internal entry point used by auto-start services; hide it from help
|
|
61
|
+
program.addCommand(createStartCommand(DEFAULT_API_URL), { hidden: true });
|
|
62
|
+
program.addCommand(createStopCommand(DEFAULT_API_URL));
|
|
63
|
+
program.addCommand(createSyncCommand(DEFAULT_API_URL));
|
|
64
|
+
program.addCommand(createShowCommand(DEFAULT_API_URL));
|
|
65
|
+
program.addCommand(createStatusCommand(DEFAULT_API_URL));
|
|
66
|
+
program.addCommand(createLogoutCommand(DEFAULT_API_URL));
|
|
67
|
+
// Parse and execute
|
|
68
|
+
program.parse(process.argv);
|
|
69
|
+
// Show help if no command provided
|
|
70
|
+
if (process.argv.length === 2) {
|
|
71
|
+
console.log('');
|
|
72
|
+
program.outputHelp();
|
|
73
|
+
console.log('\nExamples:');
|
|
74
|
+
console.log(' promptcase init Authenticate (and auto-start daemon)');
|
|
75
|
+
console.log(' promptcase status Show auth + daemon + sync status');
|
|
76
|
+
console.log(' promptcase sync Force sync + run diagnostics');
|
|
77
|
+
console.log(' promptcase show Show last 15 synced prompts');
|
|
78
|
+
console.log(' promptcase stop Stop daemon (auto-start kept)');
|
|
79
|
+
console.log(' promptcase logout Logout and invalidate token\n');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Handle uncaught errors
|
|
83
|
+
process.on('uncaughtException', (error) => {
|
|
84
|
+
console.error('\nā Unexpected error:', error.message);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
// Log unhandled rejections but don't exit. The daemon and other commands
|
|
88
|
+
// handle their own promise errors; a transient network failure shouldn't
|
|
89
|
+
// crash a long-running background process.
|
|
90
|
+
process.on('unhandledRejection', (reason) => {
|
|
91
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
92
|
+
console.error('\nā ļø Unhandled promise rejection:', message);
|
|
93
|
+
});
|
|
94
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration store for CLI credentials and settings
|
|
3
|
+
*/
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { DEFAULT_API_URL, SYNC_INTERVAL_MS } from './constants.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
// From compiled file at `dist/lib/config.js`, package.json lives at
|
|
11
|
+
// `dist/../package.json` (two levels up). Reading via createRequire with
|
|
12
|
+
// an absolute path avoids any path-resolution drift between dev and prod.
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
const CLI_VERSION = pkg.version;
|
|
15
|
+
export class ConfigService {
|
|
16
|
+
store;
|
|
17
|
+
configDir;
|
|
18
|
+
constructor() {
|
|
19
|
+
// Use OS-appropriate config directory
|
|
20
|
+
this.configDir = path.join(os.homedir(), '.promptcase');
|
|
21
|
+
this.store = new Conf({
|
|
22
|
+
projectName: 'promptcase',
|
|
23
|
+
projectVersion: CLI_VERSION,
|
|
24
|
+
defaults: {
|
|
25
|
+
credentials: null,
|
|
26
|
+
daemon: {
|
|
27
|
+
status: {
|
|
28
|
+
isRunning: false,
|
|
29
|
+
pid: undefined,
|
|
30
|
+
startedAt: null,
|
|
31
|
+
lastSyncAt: null,
|
|
32
|
+
nextSyncAt: null,
|
|
33
|
+
promptsSynced: 0,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
settings: {
|
|
37
|
+
syncInterval: SYNC_INTERVAL_MS,
|
|
38
|
+
apiUrl: DEFAULT_API_URL,
|
|
39
|
+
lastKnownHistoryFile: null,
|
|
40
|
+
lastSyncCursor: null,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// Credentials
|
|
46
|
+
async getCredentials() {
|
|
47
|
+
return this.store.get('credentials');
|
|
48
|
+
}
|
|
49
|
+
async setCredentials(credentials) {
|
|
50
|
+
this.store.set('credentials', credentials);
|
|
51
|
+
}
|
|
52
|
+
async clearCredentials() {
|
|
53
|
+
this.store.delete('credentials');
|
|
54
|
+
}
|
|
55
|
+
async hasCredentials() {
|
|
56
|
+
const creds = await this.getCredentials();
|
|
57
|
+
return creds !== null;
|
|
58
|
+
}
|
|
59
|
+
// Daemon status
|
|
60
|
+
async getDaemonStatus() {
|
|
61
|
+
return this.store.get('daemon.status');
|
|
62
|
+
}
|
|
63
|
+
async setDaemonStatus(status) {
|
|
64
|
+
this.store.set('daemon.status', status);
|
|
65
|
+
}
|
|
66
|
+
// Settings
|
|
67
|
+
async getSyncInterval() {
|
|
68
|
+
return this.store.get('settings.syncInterval');
|
|
69
|
+
}
|
|
70
|
+
async setSyncInterval(interval) {
|
|
71
|
+
this.store.set('settings.syncInterval', interval);
|
|
72
|
+
}
|
|
73
|
+
async getApiUrl() {
|
|
74
|
+
return this.store.get('settings.apiUrl');
|
|
75
|
+
}
|
|
76
|
+
async setApiUrl(url) {
|
|
77
|
+
this.store.set('settings.apiUrl', url);
|
|
78
|
+
}
|
|
79
|
+
async getLastKnownHistoryFile() {
|
|
80
|
+
return this.store.get('settings.lastKnownHistoryFile');
|
|
81
|
+
}
|
|
82
|
+
async setLastKnownHistoryFile(file) {
|
|
83
|
+
this.store.set('settings.lastKnownHistoryFile', file);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* ISO timestamp string of the most recent prompt successfully synced.
|
|
87
|
+
* Used as a cursor so subsequent syncs only fetch newer prompts.
|
|
88
|
+
* `null` means "no cursor yet, do a full backfill".
|
|
89
|
+
*/
|
|
90
|
+
async getLastSyncCursor() {
|
|
91
|
+
return this.store.get('settings.lastSyncCursor');
|
|
92
|
+
}
|
|
93
|
+
async setLastSyncCursor(cursor) {
|
|
94
|
+
this.store.set('settings.lastSyncCursor', cursor);
|
|
95
|
+
}
|
|
96
|
+
// Check if token is expired
|
|
97
|
+
async isTokenExpired() {
|
|
98
|
+
const creds = await this.getCredentials();
|
|
99
|
+
if (!creds)
|
|
100
|
+
return true;
|
|
101
|
+
const expiresAt = new Date(creds.expiresAt);
|
|
102
|
+
return expiresAt.getTime() < Date.now();
|
|
103
|
+
}
|
|
104
|
+
// Get config directory path
|
|
105
|
+
getConfigDir() {
|
|
106
|
+
return this.configDir;
|
|
107
|
+
}
|
|
108
|
+
// Get credentials file path
|
|
109
|
+
getCredentialsFilePath() {
|
|
110
|
+
return path.join(this.configDir, 'credentials');
|
|
111
|
+
}
|
|
112
|
+
// Clear all data (for logout)
|
|
113
|
+
async clearAll() {
|
|
114
|
+
this.store.clear();
|
|
115
|
+
}
|
|
116
|
+
// Check if authenticated (has valid credentials)
|
|
117
|
+
async isAuthenticated() {
|
|
118
|
+
const hasCreds = await this.hasCredentials();
|
|
119
|
+
if (!hasCreds)
|
|
120
|
+
return false;
|
|
121
|
+
return !(await this.isTokenExpired());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Singleton instance
|
|
125
|
+
let configInstance = null;
|
|
126
|
+
export function getConfig() {
|
|
127
|
+
if (!configInstance) {
|
|
128
|
+
configInstance = new ConfigService();
|
|
129
|
+
}
|
|
130
|
+
return configInstance;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized constants for the CLI
|
|
3
|
+
*
|
|
4
|
+
* Previously these were duplicated across 7+ files. Keeping them in one
|
|
5
|
+
* place makes it obvious what to change when the production URL changes.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_API_URL = 'https://web-six-iota-a0lchij54v.vercel.app';
|
|
8
|
+
/** Where users generate a CLI token from the web app. */
|
|
9
|
+
export const CLI_TOKEN_URL = `${DEFAULT_API_URL}/dashboard/devices/cli-token`;
|
|
10
|
+
/** Sync interval (1 minute) ā daemon loop ticks this often. */
|
|
11
|
+
export const SYNC_INTERVAL_MS = 60_000;
|
|
12
|
+
/** Max prompts fetched per sync (avoid huge payloads). */
|
|
13
|
+
export const MAX_PROMPTS_PER_SYNC = 100;
|
|
14
|
+
/** Prompts shorter than this are filtered out as noise. */
|
|
15
|
+
export const PROMPT_MIN_LENGTH = 10;
|
|
16
|
+
/** Friendly process name shown in `ps`/Activity Monitor when daemon is running. */
|
|
17
|
+
export const DAEMON_PROCESS_TITLE = 'PromptCase Daemon';
|
|
18
|
+
//# sourceMappingURL=constants.js.map
|
package/dist/lib/path.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ESM path helpers
|
|
3
|
+
*
|
|
4
|
+
* Node.js ESM does not provide `__filename` / `__dirname`. This module
|
|
5
|
+
* computes them once (per module evaluation) and exposes:
|
|
6
|
+
* - `scriptPath()` ā absolute path to dist/index.js (used to spawn
|
|
7
|
+
* detached daemon processes)
|
|
8
|
+
* - `here()` ā directory of the current source file (for any other
|
|
9
|
+
* call site that needs `__dirname`)
|
|
10
|
+
*/
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { dirname, resolve } from 'path';
|
|
13
|
+
const here_filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const here_dirname = dirname(here_filename);
|
|
15
|
+
/**
|
|
16
|
+
* Absolute path to this CLI's compiled entry point. Used to spawn the
|
|
17
|
+
* daemon process detached from the parent's terminal.
|
|
18
|
+
*/
|
|
19
|
+
export function scriptPath() {
|
|
20
|
+
return resolve(here_dirname, '..', 'index.js');
|
|
21
|
+
}
|
|
22
|
+
/** Absolute path to the directory containing the compiled file. */
|
|
23
|
+
export function here() {
|
|
24
|
+
return here_dirname;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=path.js.map
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API service for communicating with PromptCase backend
|
|
3
|
+
*/
|
|
4
|
+
import got from 'got';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
export class APIService {
|
|
7
|
+
apiUrl;
|
|
8
|
+
accessToken = null;
|
|
9
|
+
refreshToken = null;
|
|
10
|
+
constructor(apiUrl) {
|
|
11
|
+
this.apiUrl = apiUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Set authentication tokens
|
|
15
|
+
*/
|
|
16
|
+
setTokens(accessToken, refreshToken) {
|
|
17
|
+
this.accessToken = accessToken;
|
|
18
|
+
this.refreshToken = refreshToken;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clear authentication tokens
|
|
22
|
+
*/
|
|
23
|
+
clearTokens() {
|
|
24
|
+
this.accessToken = null;
|
|
25
|
+
this.refreshToken = null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if authenticated
|
|
29
|
+
*/
|
|
30
|
+
isAuthenticated() {
|
|
31
|
+
return this.accessToken !== null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Read the current access token. Exposed for callers that need to persist
|
|
35
|
+
* refreshed tokens (e.g. the daemon's hourly refresh-write task).
|
|
36
|
+
*/
|
|
37
|
+
getAccessToken() {
|
|
38
|
+
return this.accessToken;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read the current refresh token.
|
|
42
|
+
*/
|
|
43
|
+
getRefreshToken() {
|
|
44
|
+
return this.refreshToken;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Refresh the access token using refresh token
|
|
48
|
+
*/
|
|
49
|
+
async refreshAccessToken() {
|
|
50
|
+
if (!this.refreshToken) {
|
|
51
|
+
throw new Error('No refresh token available');
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const response = await got.post(`${this.apiUrl}/api/auth/refresh`, {
|
|
55
|
+
json: {
|
|
56
|
+
refresh_token: this.refreshToken,
|
|
57
|
+
},
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
},
|
|
61
|
+
responseType: 'json',
|
|
62
|
+
});
|
|
63
|
+
const data = response.body;
|
|
64
|
+
this.accessToken = data.access_token;
|
|
65
|
+
if (data.refresh_token) {
|
|
66
|
+
this.refreshToken = data.refresh_token;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
console.error('Failed to refresh token:', error.message);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Make an authenticated API request
|
|
77
|
+
*/
|
|
78
|
+
async request(endpoint, options = {}) {
|
|
79
|
+
const { method = 'GET', body, retry = true } = options;
|
|
80
|
+
if (!this.accessToken) {
|
|
81
|
+
throw new Error('Not authenticated');
|
|
82
|
+
}
|
|
83
|
+
const headers = {
|
|
84
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const response = await got(`${this.apiUrl}${endpoint}`, {
|
|
89
|
+
method,
|
|
90
|
+
json: body,
|
|
91
|
+
headers,
|
|
92
|
+
responseType: 'json',
|
|
93
|
+
});
|
|
94
|
+
return response.body;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// Handle 401 - try to refresh token
|
|
98
|
+
if (error.response?.statusCode === 401 && retry) {
|
|
99
|
+
const refreshed = await this.refreshAccessToken();
|
|
100
|
+
if (refreshed) {
|
|
101
|
+
// Retry the request with new token
|
|
102
|
+
return this.request(endpoint, { method, body, retry: false });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Re-throw with more info
|
|
106
|
+
if (error.response?.body) {
|
|
107
|
+
throw new Error(error.response.body.error || error.message);
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Sync prompts to the backend via /api/prompts/batch
|
|
114
|
+
*
|
|
115
|
+
* The batch endpoint accepts an array of prompt objects and dedupes by
|
|
116
|
+
* content_hash (falls back to SHA-256 of content if not provided). Returns
|
|
117
|
+
* `{synced, failed, total}` so the daemon can update its counters.
|
|
118
|
+
*/
|
|
119
|
+
async syncPrompts(prompts) {
|
|
120
|
+
if (!this.accessToken) {
|
|
121
|
+
throw new Error('Not authenticated');
|
|
122
|
+
}
|
|
123
|
+
// Compute SHA-256 for any prompt missing a content_hash, so the server
|
|
124
|
+
// can dedup reliably. Prompts that already have a hash pass through as-is.
|
|
125
|
+
const payload = prompts.map((p) => ({
|
|
126
|
+
...p,
|
|
127
|
+
content_hash: p.content_hash ?? generatePromptHash(p.content),
|
|
128
|
+
}));
|
|
129
|
+
const response = await got.post(`${this.apiUrl}/api/prompts/batch`, {
|
|
130
|
+
json: { prompts: payload },
|
|
131
|
+
headers: {
|
|
132
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
},
|
|
135
|
+
responseType: 'json',
|
|
136
|
+
// Server processes up to 100 prompts sequentially with dedup; observed
|
|
137
|
+
// ~56s for a full batch on slow networks. Use 90s headroom.
|
|
138
|
+
timeout: { request: 90_000 },
|
|
139
|
+
});
|
|
140
|
+
const body = response.body;
|
|
141
|
+
return {
|
|
142
|
+
synced: body.synced ?? 0,
|
|
143
|
+
failed: body.failed ?? 0,
|
|
144
|
+
total: body.total ?? payload.length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Get recent prompts from backend
|
|
149
|
+
*/
|
|
150
|
+
async getRecentPrompts(limit = 15) {
|
|
151
|
+
const response = await this.request(`/api/prompts?limit=${limit}`);
|
|
152
|
+
return response.prompts;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Verify token is valid by calling /api/auth/device
|
|
156
|
+
* Returns user info if valid
|
|
157
|
+
*/
|
|
158
|
+
async verifyToken() {
|
|
159
|
+
if (!this.accessToken)
|
|
160
|
+
return null;
|
|
161
|
+
try {
|
|
162
|
+
const response = await got(`${this.apiUrl}/api/auth/device`, {
|
|
163
|
+
method: 'GET',
|
|
164
|
+
headers: {
|
|
165
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
166
|
+
'Content-Type': 'application/json',
|
|
167
|
+
},
|
|
168
|
+
responseType: 'json',
|
|
169
|
+
});
|
|
170
|
+
const data = response.body;
|
|
171
|
+
return {
|
|
172
|
+
userId: data.device?.client_id || 'unknown',
|
|
173
|
+
deviceId: data.device?.id,
|
|
174
|
+
deviceName: data.device?.name || 'CLI',
|
|
175
|
+
deviceType: data.device?.type || 'cli',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Revoke device token (logout)
|
|
184
|
+
*/
|
|
185
|
+
async revokeDevice() {
|
|
186
|
+
if (!this.accessToken)
|
|
187
|
+
return false;
|
|
188
|
+
try {
|
|
189
|
+
await got(`${this.apiUrl}/api/auth/device`, {
|
|
190
|
+
method: 'DELETE',
|
|
191
|
+
headers: {
|
|
192
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
},
|
|
195
|
+
responseType: 'json',
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.error('Failed to revoke device:', error.message);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Generate hash for deduplication
|
|
207
|
+
*/
|
|
208
|
+
export function generatePromptHash(content) {
|
|
209
|
+
return createHash('sha256').update(content).digest('hex');
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=api.js.map
|