clocktopus 1.0.0
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 +297 -0
- package/dist/clockify.js +188 -0
- package/dist/config/clockify.js +7 -0
- package/dist/dashboard/routes/clockify.js +25 -0
- package/dist/dashboard/routes/data.js +61 -0
- package/dist/dashboard/routes/google.js +49 -0
- package/dist/dashboard/routes/jira.js +81 -0
- package/dist/dashboard/routes/monitor.js +45 -0
- package/dist/dashboard/routes/status.js +84 -0
- package/dist/dashboard/routes/timer.js +60 -0
- package/dist/dashboard/server.js +24 -0
- package/dist/dashboard/views.js +843 -0
- package/dist/index.js +277 -0
- package/dist/lib/atlassian.js +93 -0
- package/dist/lib/credentials.js +10 -0
- package/dist/lib/db.js +189 -0
- package/dist/lib/google.js +57 -0
- package/dist/lib/http-client.js +20 -0
- package/dist/lib/jira.js +82 -0
- package/dist/lib/monitors/display-monitor-factory.js +10 -0
- package/dist/lib/monitors/display-monitor-macos.js +67 -0
- package/dist/lib/monitors/idle-monitor.js +33 -0
- package/dist/lib/platform-events.js +37 -0
- package/dist/lib/timer-controller.js +56 -0
- package/dist/proxy/src/index.js +118 -0
- package/dist/scripts/db-cleanup.js +4 -0
- package/dist/scripts/google-auth.js +31 -0
- package/dist/scripts/log-calendar-events.js +141 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { Clockify } from './clockify.js';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { completeLatestSession, getLatestSession } from './lib/db.js';
|
|
10
|
+
import { stopJiraTimer } from './lib/jira.js';
|
|
11
|
+
import { startDashboard } from './dashboard/server.js';
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
const program = new Command();
|
|
15
|
+
const clockify = new Clockify();
|
|
16
|
+
async function getLocalProjects() {
|
|
17
|
+
const dataDir = path.join(__dirname, '../data');
|
|
18
|
+
const localProjectsPath = path.join(dataDir, 'local-projects.json');
|
|
19
|
+
try {
|
|
20
|
+
// Ensure the data directory exists
|
|
21
|
+
await fs.promises.mkdir(dataDir, { recursive: true });
|
|
22
|
+
// If the file does not exist, create it with an empty array
|
|
23
|
+
try {
|
|
24
|
+
await fs.promises.access(localProjectsPath, fs.constants.F_OK);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
await fs.promises.writeFile(localProjectsPath, '[]', 'utf8');
|
|
28
|
+
}
|
|
29
|
+
const data = await fs.promises.readFile(localProjectsPath, 'utf8');
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
}
|
|
32
|
+
catch (_error) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function getWorkspaceAndUser() {
|
|
37
|
+
const user = await clockify.getUser();
|
|
38
|
+
if (!user) {
|
|
39
|
+
console.log(chalk.red('[index] Could not connect to Clockify. Please check your API key.'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const workspaceId = user.defaultWorkspace;
|
|
43
|
+
const userId = user.id;
|
|
44
|
+
return {
|
|
45
|
+
workspaceId,
|
|
46
|
+
userId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
program.name('clocktopus').description('CLI time-tracking automation for Clockify').version('1.0.0');
|
|
50
|
+
program
|
|
51
|
+
.command('start')
|
|
52
|
+
.description('Start a new time entry. Select a project interactively.')
|
|
53
|
+
.argument('[message]', 'Description for the time entry')
|
|
54
|
+
.option('-j, --jira <ticket>', 'Jira ticket number')
|
|
55
|
+
.action(async (message, options) => {
|
|
56
|
+
const { workspaceId } = await getWorkspaceAndUser();
|
|
57
|
+
let projects = await clockify.getProjects(workspaceId);
|
|
58
|
+
let localProjects = await getLocalProjects();
|
|
59
|
+
if (localProjects.length === 0) {
|
|
60
|
+
// If local-projects.json is empty or doesn't exist, populate it with all project IDs and names
|
|
61
|
+
const allProjects = projects.map((p) => ({ id: p.id, name: p.name }));
|
|
62
|
+
const localProjectsPath = path.join(__dirname, '../data/local-projects.json');
|
|
63
|
+
fs.writeFileSync(localProjectsPath, JSON.stringify(allProjects, null, 2), 'utf8');
|
|
64
|
+
console.log(chalk.green('All projects have been saved to data/local-projects.json. Please edit this file to select your preferred projects.'));
|
|
65
|
+
localProjects = allProjects;
|
|
66
|
+
}
|
|
67
|
+
if (localProjects.length > 0) {
|
|
68
|
+
const localProjectIds = localProjects.map((p) => p.id);
|
|
69
|
+
projects = projects.filter((p) => localProjectIds.includes(p.id));
|
|
70
|
+
}
|
|
71
|
+
if (!projects || projects.length === 0) {
|
|
72
|
+
console.log(chalk.yellow('No projects found in your workspace.'));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { selectedProjectId } = await inquirer.prompt([
|
|
76
|
+
{
|
|
77
|
+
type: 'list',
|
|
78
|
+
name: 'selectedProjectId',
|
|
79
|
+
message: 'Which project do you want to work on?',
|
|
80
|
+
choices: projects.map((p) => ({ name: p.name, value: p.id })),
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
const entry = await clockify.startTimer(workspaceId, selectedProjectId, message, options.jira);
|
|
84
|
+
if (entry) {
|
|
85
|
+
const projectName = projects.find((p) => p.id === selectedProjectId)?.name;
|
|
86
|
+
console.log(chalk.green(`Timer started for project: ${chalk.bold(projectName)}`));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
program
|
|
90
|
+
.command('stop')
|
|
91
|
+
.description('Stop the currently running time entry.')
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
94
|
+
const latestSession = getLatestSession();
|
|
95
|
+
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
96
|
+
if (stoppedEntry) {
|
|
97
|
+
const completedAt = new Date().toISOString();
|
|
98
|
+
completeLatestSession(completedAt);
|
|
99
|
+
if (latestSession.jiraTicket) {
|
|
100
|
+
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
|
|
101
|
+
if (timeSpentSeconds >= 60) {
|
|
102
|
+
try {
|
|
103
|
+
await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.error('Error stopping Jira timer:', error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
console.log(chalk.red('Timer stopped.'));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log(chalk.yellow('No timer was running.'));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
program
|
|
117
|
+
.command('status')
|
|
118
|
+
.description('Check the status of the current timer.')
|
|
119
|
+
.action(async () => {
|
|
120
|
+
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
121
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
122
|
+
if (activeEntry) {
|
|
123
|
+
const startTime = new Date(activeEntry.timeInterval.start);
|
|
124
|
+
const duration = (new Date().getTime() - startTime.getTime()) / 1000; // in seconds
|
|
125
|
+
const hours = Math.floor(duration / 3600);
|
|
126
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
127
|
+
console.log(chalk.green('🕒 A timer is currently running.'));
|
|
128
|
+
console.log(` - ${chalk.bold('Project:')} ${activeEntry.project.name}`);
|
|
129
|
+
console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
console.log(chalk.yellow('No timer is currently running.'));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
function sleep(ms) {
|
|
136
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
137
|
+
}
|
|
138
|
+
program
|
|
139
|
+
.command('monitor')
|
|
140
|
+
.description('Monitor system idle time and screen state, and stop the Clockify timer if idle or screen is off.')
|
|
141
|
+
.action(async () => {
|
|
142
|
+
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
143
|
+
async function stopTimerAndLog(reason) {
|
|
144
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
145
|
+
if (!activeEntry)
|
|
146
|
+
return false;
|
|
147
|
+
console.log(chalk.yellow(reason));
|
|
148
|
+
const completedAt = new Date().toISOString();
|
|
149
|
+
const latestSession = getLatestSession();
|
|
150
|
+
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
151
|
+
if (!stoppedEntry)
|
|
152
|
+
return false;
|
|
153
|
+
completeLatestSession(completedAt, true);
|
|
154
|
+
if (latestSession?.jiraTicket) {
|
|
155
|
+
const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
|
|
156
|
+
if (timeSpentSeconds >= 60) {
|
|
157
|
+
try {
|
|
158
|
+
await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error('Error stopping Jira timer:', err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
console.log(chalk.red('Timer stopped.'));
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
// Safer restart w/ cooldown; only resume a recent auto-completed session
|
|
169
|
+
let lastResumeAt = 0;
|
|
170
|
+
const RESUME_COOLDOWN_MS = 10000;
|
|
171
|
+
async function safeRestartTimerIfNeeded() {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (now - lastResumeAt < RESUME_COOLDOWN_MS)
|
|
174
|
+
return;
|
|
175
|
+
// Small delay lets services settle after wake/activity
|
|
176
|
+
await sleep(800);
|
|
177
|
+
const latestSession = getLatestSession();
|
|
178
|
+
if (!latestSession)
|
|
179
|
+
return;
|
|
180
|
+
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
181
|
+
if (activeEntry)
|
|
182
|
+
return;
|
|
183
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
184
|
+
const completedAt = latestSession.completedAt ? new Date(latestSession.completedAt).getTime() : 0;
|
|
185
|
+
const eligible = latestSession.isAutoCompleted && completedAt > twoHoursAgo && !!latestSession.projectId;
|
|
186
|
+
if (!eligible)
|
|
187
|
+
return;
|
|
188
|
+
await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description);
|
|
189
|
+
console.log(chalk.green('Timer restarted for the last used project.'));
|
|
190
|
+
lastResumeAt = Date.now();
|
|
191
|
+
}
|
|
192
|
+
console.log(chalk.blue('Monitoring display events (Unified Log) and idle time...'));
|
|
193
|
+
let isLocked = false;
|
|
194
|
+
let pollInterval = null;
|
|
195
|
+
console.log(chalk.blue('Monitoring display/lock state (macos-notification-state) and idle time...'));
|
|
196
|
+
try {
|
|
197
|
+
if (process.platform === 'darwin') {
|
|
198
|
+
const nsModule = await import('macos-notification-state');
|
|
199
|
+
const getSessionState = nsModule.default?.getSessionState || nsModule.getSessionState;
|
|
200
|
+
if (!getSessionState) {
|
|
201
|
+
throw new Error('getSessionState not found in module');
|
|
202
|
+
}
|
|
203
|
+
pollInterval = setInterval(async () => {
|
|
204
|
+
try {
|
|
205
|
+
const state = getSessionState();
|
|
206
|
+
const locked = state === 'SESSION_SCREEN_IS_LOCKED';
|
|
207
|
+
if (locked && !isLocked) {
|
|
208
|
+
isLocked = true;
|
|
209
|
+
await stopTimerAndLog('Screen is locked/off. Stopping timer...');
|
|
210
|
+
}
|
|
211
|
+
else if (!locked && isLocked) {
|
|
212
|
+
console.log(chalk.green('Screen is unlocked/on. Attempting to restart timer...'));
|
|
213
|
+
await safeRestartTimerIfNeeded();
|
|
214
|
+
isLocked = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
console.error('Error polling session state:', error);
|
|
219
|
+
}
|
|
220
|
+
}, 3000);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
console.log(chalk.yellow('Display monitoring (lock state) is only supported on macOS. Skipping.'));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
console.error(chalk.red('Failed to load macos-notification-state. Display monitoring will be disabled.'));
|
|
228
|
+
console.error(err);
|
|
229
|
+
}
|
|
230
|
+
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
|
|
231
|
+
let lastIdle = false;
|
|
232
|
+
const idleInterval = setInterval(async () => {
|
|
233
|
+
try {
|
|
234
|
+
const idleModule = await import('desktop-idle');
|
|
235
|
+
const idleTime = idleModule.default.getIdleTime();
|
|
236
|
+
if (idleTime >= IDLE_THRESHOLD_SECONDS) {
|
|
237
|
+
const stopped = await stopTimerAndLog(`System idle for ${Math.floor(idleTime)} seconds. Stopping timer...`);
|
|
238
|
+
if (stopped)
|
|
239
|
+
lastIdle = true;
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
// User active again → resume even if display log events were missed
|
|
243
|
+
if (lastIdle) {
|
|
244
|
+
await safeRestartTimerIfNeeded();
|
|
245
|
+
}
|
|
246
|
+
lastIdle = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
// swallow; desktop-idle can occasionally throw on wake races
|
|
251
|
+
}
|
|
252
|
+
}, 5000);
|
|
253
|
+
function cleanupAndExit(code = 0) {
|
|
254
|
+
try {
|
|
255
|
+
clearInterval(idleInterval);
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
try {
|
|
259
|
+
if (pollInterval)
|
|
260
|
+
clearInterval(pollInterval);
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
process.exit(code);
|
|
264
|
+
}
|
|
265
|
+
process.on('SIGINT', () => {
|
|
266
|
+
console.log(chalk.gray('\nStopping monitor...'));
|
|
267
|
+
cleanupAndExit(0);
|
|
268
|
+
});
|
|
269
|
+
process.on('SIGTERM', () => cleanupAndExit(0));
|
|
270
|
+
});
|
|
271
|
+
program
|
|
272
|
+
.command('dash')
|
|
273
|
+
.description('Start the Clocktopus web dashboard on localhost:4001.')
|
|
274
|
+
.action(() => {
|
|
275
|
+
startDashboard();
|
|
276
|
+
});
|
|
277
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { resolveCredential } from './credentials.js';
|
|
3
|
+
import { getAtlassianToken, updateAtlassianAccessToken } from './db.js';
|
|
4
|
+
// Cloudflare Worker proxy that holds the client secret
|
|
5
|
+
const AUTH_PROXY_URL = 'https://clocktopus-auth.clocktopus.workers.dev/';
|
|
6
|
+
// Fallback: direct Atlassian API (when user provides their own credentials)
|
|
7
|
+
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
|
|
8
|
+
const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
|
|
9
|
+
const REDIRECT_URI = 'http://localhost:4001/api/jira/callback';
|
|
10
|
+
function hasLocalCredentials() {
|
|
11
|
+
return !!(resolveCredential('ATLASSIAN_CLIENT_ID') && resolveCredential('ATLASSIAN_CLIENT_SECRET'));
|
|
12
|
+
}
|
|
13
|
+
export async function getAtlassianAuthUrl() {
|
|
14
|
+
if (hasLocalCredentials()) {
|
|
15
|
+
const clientId = resolveCredential('ATLASSIAN_CLIENT_ID');
|
|
16
|
+
const params = new URLSearchParams({
|
|
17
|
+
audience: 'api.atlassian.com',
|
|
18
|
+
client_id: clientId,
|
|
19
|
+
scope: 'read:jira-work write:jira-work read:jira-user read:me offline_access',
|
|
20
|
+
redirect_uri: REDIRECT_URI,
|
|
21
|
+
response_type: 'code',
|
|
22
|
+
prompt: 'consent',
|
|
23
|
+
});
|
|
24
|
+
return `https://auth.atlassian.com/authorize?${params.toString()}`;
|
|
25
|
+
}
|
|
26
|
+
// Use proxy to get auth URL
|
|
27
|
+
const res = await axios.get(`${AUTH_PROXY_URL}/atlassian/auth-url`, {
|
|
28
|
+
params: { redirect_uri: REDIRECT_URI },
|
|
29
|
+
});
|
|
30
|
+
return res.data.url;
|
|
31
|
+
}
|
|
32
|
+
export async function exchangeCodeForTokens(code) {
|
|
33
|
+
if (hasLocalCredentials()) {
|
|
34
|
+
const res = await axios.post(ATLASSIAN_TOKEN_URL, {
|
|
35
|
+
grant_type: 'authorization_code',
|
|
36
|
+
client_id: resolveCredential('ATLASSIAN_CLIENT_ID'),
|
|
37
|
+
client_secret: resolveCredential('ATLASSIAN_CLIENT_SECRET'),
|
|
38
|
+
code,
|
|
39
|
+
redirect_uri: REDIRECT_URI,
|
|
40
|
+
});
|
|
41
|
+
return res.data;
|
|
42
|
+
}
|
|
43
|
+
// Use proxy
|
|
44
|
+
const res = await axios.post(`${AUTH_PROXY_URL}/atlassian/token`, {
|
|
45
|
+
grant_type: 'authorization_code',
|
|
46
|
+
code,
|
|
47
|
+
redirect_uri: REDIRECT_URI,
|
|
48
|
+
});
|
|
49
|
+
return res.data;
|
|
50
|
+
}
|
|
51
|
+
export async function refreshAccessToken(refreshToken) {
|
|
52
|
+
if (hasLocalCredentials()) {
|
|
53
|
+
const res = await axios.post(ATLASSIAN_TOKEN_URL, {
|
|
54
|
+
grant_type: 'refresh_token',
|
|
55
|
+
client_id: resolveCredential('ATLASSIAN_CLIENT_ID'),
|
|
56
|
+
client_secret: resolveCredential('ATLASSIAN_CLIENT_SECRET'),
|
|
57
|
+
refresh_token: refreshToken,
|
|
58
|
+
});
|
|
59
|
+
return res.data;
|
|
60
|
+
}
|
|
61
|
+
// Use proxy
|
|
62
|
+
const res = await axios.post(`${AUTH_PROXY_URL}/atlassian/token`, {
|
|
63
|
+
grant_type: 'refresh_token',
|
|
64
|
+
refresh_token: refreshToken,
|
|
65
|
+
});
|
|
66
|
+
return res.data;
|
|
67
|
+
}
|
|
68
|
+
export async function getAccessibleResources(accessToken) {
|
|
69
|
+
const res = await axios.get(ATLASSIAN_RESOURCES_URL, {
|
|
70
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
|
|
71
|
+
});
|
|
72
|
+
return res.data;
|
|
73
|
+
}
|
|
74
|
+
export async function getValidAccessToken() {
|
|
75
|
+
const token = getAtlassianToken();
|
|
76
|
+
if (!token)
|
|
77
|
+
return null;
|
|
78
|
+
const expiresAt = new Date(token.expires_at).getTime();
|
|
79
|
+
const bufferMs = 5 * 60 * 1000;
|
|
80
|
+
if (Date.now() < expiresAt - bufferMs) {
|
|
81
|
+
return { access_token: token.access_token, cloud_id: token.cloud_id };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const refreshed = await refreshAccessToken(token.refresh_token);
|
|
85
|
+
const newExpiresAt = new Date(Date.now() + refreshed.expires_in * 1000).toISOString();
|
|
86
|
+
updateAtlassianAccessToken(refreshed.access_token, newExpiresAt);
|
|
87
|
+
return { access_token: refreshed.access_token, cloud_id: token.cloud_id };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
console.error('Failed to refresh Atlassian token:', error instanceof Error ? error.message : error);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getCredential, setCredential } from './db.js';
|
|
2
|
+
export function resolveCredential(key) {
|
|
3
|
+
const dbValue = getCredential(key);
|
|
4
|
+
if (dbValue)
|
|
5
|
+
return dbValue;
|
|
6
|
+
return process.env[key];
|
|
7
|
+
}
|
|
8
|
+
export function saveCredential(key, value) {
|
|
9
|
+
setCredential(key, value);
|
|
10
|
+
}
|
package/dist/lib/db.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { Database } from 'bun:sqlite';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const DB_DIR = path.join(process.cwd(), 'data/db');
|
|
6
|
+
const DB_PATH = path.join(DB_DIR, 'sessions.db');
|
|
7
|
+
if (!fs.existsSync(DB_DIR)) {
|
|
8
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
const SessionSchema = z.object({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
projectId: z.string(),
|
|
13
|
+
description: z.string(),
|
|
14
|
+
startedAt: z.string(),
|
|
15
|
+
completedAt: z.string().nullable(),
|
|
16
|
+
isAutoCompleted: z.number(),
|
|
17
|
+
jiraTicket: z.string().nullable(),
|
|
18
|
+
});
|
|
19
|
+
let dbInstance = null;
|
|
20
|
+
function getDb() {
|
|
21
|
+
if (!dbInstance) {
|
|
22
|
+
dbInstance = new Database(DB_PATH);
|
|
23
|
+
dbInstance.exec('PRAGMA journal_mode = WAL');
|
|
24
|
+
dbInstance.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
projectId TEXT NOT NULL,
|
|
28
|
+
description TEXT NOT NULL,
|
|
29
|
+
startedAt TEXT NOT NULL,
|
|
30
|
+
completedAt TEXT,
|
|
31
|
+
isAutoCompleted INTEGER DEFAULT 0,
|
|
32
|
+
jiraTicket TEXT
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
dbInstance.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS google_tokens (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
token TEXT NOT NULL,
|
|
39
|
+
createdAt TEXT NOT NULL
|
|
40
|
+
)
|
|
41
|
+
`);
|
|
42
|
+
dbInstance.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS event_projects (
|
|
44
|
+
eventName TEXT PRIMARY KEY,
|
|
45
|
+
projectId TEXT
|
|
46
|
+
)
|
|
47
|
+
`);
|
|
48
|
+
dbInstance.exec(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
50
|
+
key TEXT PRIMARY KEY,
|
|
51
|
+
value TEXT NOT NULL,
|
|
52
|
+
updatedAt TEXT NOT NULL
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
dbInstance.exec(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
name TEXT NOT NULL,
|
|
59
|
+
active INTEGER DEFAULT 1
|
|
60
|
+
)
|
|
61
|
+
`);
|
|
62
|
+
dbInstance.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS atlassian_tokens (
|
|
64
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
65
|
+
access_token TEXT NOT NULL,
|
|
66
|
+
refresh_token TEXT NOT NULL,
|
|
67
|
+
expires_at TEXT NOT NULL,
|
|
68
|
+
cloud_id TEXT NOT NULL,
|
|
69
|
+
site_url TEXT,
|
|
70
|
+
createdAt TEXT NOT NULL
|
|
71
|
+
)
|
|
72
|
+
`);
|
|
73
|
+
}
|
|
74
|
+
return dbInstance;
|
|
75
|
+
}
|
|
76
|
+
export function getEventProject(eventName) {
|
|
77
|
+
const db = getDb();
|
|
78
|
+
const stmt = db.prepare('SELECT projectId FROM event_projects WHERE eventName = ?');
|
|
79
|
+
const row = stmt.get(eventName);
|
|
80
|
+
return row ? row.projectId : undefined;
|
|
81
|
+
}
|
|
82
|
+
export function setEventProject(eventName, projectId) {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const stmt = db.prepare('INSERT OR REPLACE INTO event_projects (eventName, projectId) VALUES (?, ?)');
|
|
85
|
+
stmt.run(eventName, projectId);
|
|
86
|
+
}
|
|
87
|
+
export function storeToken(token) {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const stmt = db.prepare('INSERT INTO google_tokens (token, createdAt) VALUES (?, ?)');
|
|
90
|
+
stmt.run(JSON.stringify(token), new Date().toISOString());
|
|
91
|
+
}
|
|
92
|
+
export function getLatestToken() {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
const stmt = db.prepare('SELECT * FROM google_tokens ORDER BY createdAt DESC LIMIT 1');
|
|
95
|
+
const row = stmt.get();
|
|
96
|
+
return row ? JSON.parse(row.token) : null;
|
|
97
|
+
}
|
|
98
|
+
export function logSessionStart(id, projectId, description, startedAt, jiraTicket) {
|
|
99
|
+
const db = getDb();
|
|
100
|
+
const stmt = db.prepare('INSERT INTO sessions (id, projectId, description, startedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, ?)');
|
|
101
|
+
stmt.run(id, projectId, description, startedAt, 0, jiraTicket ?? null);
|
|
102
|
+
}
|
|
103
|
+
export function completeLatestSession(completedAt, isAutoCompleted = false) {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
const stmt = db.prepare(`
|
|
106
|
+
UPDATE sessions
|
|
107
|
+
SET completedAt = ?, isAutoCompleted = ?
|
|
108
|
+
WHERE id = (
|
|
109
|
+
SELECT id FROM sessions WHERE completedAt IS NULL ORDER BY startedAt DESC LIMIT 1
|
|
110
|
+
)
|
|
111
|
+
`);
|
|
112
|
+
stmt.run(completedAt, isAutoCompleted ? 1 : 0);
|
|
113
|
+
}
|
|
114
|
+
export function getRecentSessions(limit = 10, offset = 0) {
|
|
115
|
+
const db = getDb();
|
|
116
|
+
const stmt = db.prepare('SELECT * FROM sessions ORDER BY startedAt DESC LIMIT ? OFFSET ?');
|
|
117
|
+
return stmt.all(limit, offset);
|
|
118
|
+
}
|
|
119
|
+
export function getSessionCount() {
|
|
120
|
+
const db = getDb();
|
|
121
|
+
const stmt = db.prepare('SELECT COUNT(*) as count FROM sessions');
|
|
122
|
+
const row = stmt.get();
|
|
123
|
+
return row.count;
|
|
124
|
+
}
|
|
125
|
+
export function getLatestSession() {
|
|
126
|
+
const db = getDb();
|
|
127
|
+
const stmt = db.prepare(`
|
|
128
|
+
SELECT * FROM sessions ORDER BY startedAt DESC LIMIT 1
|
|
129
|
+
`);
|
|
130
|
+
return SessionSchema.parse(stmt.get());
|
|
131
|
+
}
|
|
132
|
+
export function deleteOldSessions(days) {
|
|
133
|
+
const db = getDb();
|
|
134
|
+
const date = new Date();
|
|
135
|
+
date.setDate(date.getDate() - days);
|
|
136
|
+
const stmt = db.prepare('DELETE FROM sessions WHERE startedAt < ?');
|
|
137
|
+
stmt.run(date.toISOString());
|
|
138
|
+
}
|
|
139
|
+
export function getCredential(key) {
|
|
140
|
+
const db = getDb();
|
|
141
|
+
const stmt = db.prepare('SELECT value FROM credentials WHERE key = ?');
|
|
142
|
+
const row = stmt.get(key);
|
|
143
|
+
return row ? row.value : null;
|
|
144
|
+
}
|
|
145
|
+
export function setCredential(key, value) {
|
|
146
|
+
const db = getDb();
|
|
147
|
+
const stmt = db.prepare('INSERT OR REPLACE INTO credentials (key, value, updatedAt) VALUES (?, ?, ?)');
|
|
148
|
+
stmt.run(key, value, new Date().toISOString());
|
|
149
|
+
}
|
|
150
|
+
export function deleteCredential(key) {
|
|
151
|
+
const db = getDb();
|
|
152
|
+
const stmt = db.prepare('DELETE FROM credentials WHERE key = ?');
|
|
153
|
+
stmt.run(key);
|
|
154
|
+
}
|
|
155
|
+
export function storeAtlassianToken(token) {
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const stmt = db.prepare('INSERT OR REPLACE INTO atlassian_tokens (id, access_token, refresh_token, expires_at, cloud_id, site_url, createdAt) VALUES (1, ?, ?, ?, ?, ?, ?)');
|
|
158
|
+
stmt.run(token.access_token, token.refresh_token, token.expires_at, token.cloud_id, token.site_url ?? null, new Date().toISOString());
|
|
159
|
+
}
|
|
160
|
+
export function getAtlassianToken() {
|
|
161
|
+
const db = getDb();
|
|
162
|
+
const stmt = db.prepare('SELECT access_token, refresh_token, expires_at, cloud_id, site_url FROM atlassian_tokens WHERE id = 1');
|
|
163
|
+
const row = stmt.get();
|
|
164
|
+
return row ?? null;
|
|
165
|
+
}
|
|
166
|
+
export function updateAtlassianAccessToken(access_token, expires_at) {
|
|
167
|
+
const db = getDb();
|
|
168
|
+
const stmt = db.prepare('UPDATE atlassian_tokens SET access_token = ?, expires_at = ? WHERE id = 1');
|
|
169
|
+
stmt.run(access_token, expires_at);
|
|
170
|
+
}
|
|
171
|
+
export function upsertProjects(projects) {
|
|
172
|
+
const db = getDb();
|
|
173
|
+
const stmt = db.prepare('INSERT INTO projects (id, name, active) VALUES (?, ?, 1) ON CONFLICT(id) DO UPDATE SET name = ?');
|
|
174
|
+
for (const p of projects) {
|
|
175
|
+
stmt.run(p.id, p.name, p.name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function getAllProjects() {
|
|
179
|
+
const db = getDb();
|
|
180
|
+
return db.prepare('SELECT * FROM projects ORDER BY name').all();
|
|
181
|
+
}
|
|
182
|
+
export function getActiveProjects() {
|
|
183
|
+
const db = getDb();
|
|
184
|
+
return db.prepare('SELECT * FROM projects WHERE active = 1 ORDER BY name').all();
|
|
185
|
+
}
|
|
186
|
+
export function toggleProjectActive(id, active) {
|
|
187
|
+
const db = getDb();
|
|
188
|
+
db.prepare('UPDATE projects SET active = ? WHERE id = ?').run(active ? 1 : 0, id);
|
|
189
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { resolveCredential } from './credentials.js';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
const REDIRECT_URI = 'http://localhost:3005/oauth2callback';
|
|
5
|
+
const AUTH_PROXY_URL = 'https://clocktopus-auth.clocktopus.workers.dev';
|
|
6
|
+
function hasLocalCredentials() {
|
|
7
|
+
return !!(resolveCredential('GOOGLE_CLIENT_ID') && resolveCredential('GOOGLE_CLIENT_SECRET'));
|
|
8
|
+
}
|
|
9
|
+
export function getAuthenticatedClient(redirectUri) {
|
|
10
|
+
if (hasLocalCredentials()) {
|
|
11
|
+
return new google.auth.OAuth2(resolveCredential('GOOGLE_CLIENT_ID'), resolveCredential('GOOGLE_CLIENT_SECRET'), redirectUri ?? REDIRECT_URI);
|
|
12
|
+
}
|
|
13
|
+
// When using proxy, create client with placeholder — token exchange goes through proxy
|
|
14
|
+
return new google.auth.OAuth2('proxy', 'proxy', redirectUri ?? REDIRECT_URI);
|
|
15
|
+
}
|
|
16
|
+
export async function getAuthUrl(redirectUri, scopes) {
|
|
17
|
+
const scope = (scopes || ['https://www.googleapis.com/auth/calendar.readonly']).join(' ');
|
|
18
|
+
if (hasLocalCredentials()) {
|
|
19
|
+
const client = getAuthenticatedClient(redirectUri);
|
|
20
|
+
return client.generateAuthUrl({ access_type: 'offline', scope, prompt: 'consent' });
|
|
21
|
+
}
|
|
22
|
+
// Use proxy
|
|
23
|
+
const res = await axios.get(`${AUTH_PROXY_URL}/google/auth-url`, {
|
|
24
|
+
params: { redirect_uri: redirectUri ?? REDIRECT_URI, scope },
|
|
25
|
+
});
|
|
26
|
+
return res.data.url;
|
|
27
|
+
}
|
|
28
|
+
export async function exchangeGoogleCode(code, redirectUri) {
|
|
29
|
+
if (hasLocalCredentials()) {
|
|
30
|
+
const client = getAuthenticatedClient(redirectUri);
|
|
31
|
+
const { tokens } = await client.getToken(code);
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
// Use proxy
|
|
35
|
+
const res = await axios.post(`${AUTH_PROXY_URL}/google/token`, {
|
|
36
|
+
grant_type: 'authorization_code',
|
|
37
|
+
code,
|
|
38
|
+
redirect_uri: redirectUri ?? REDIRECT_URI,
|
|
39
|
+
});
|
|
40
|
+
return res.data;
|
|
41
|
+
}
|
|
42
|
+
export async function getRefreshedToken(token) {
|
|
43
|
+
if (hasLocalCredentials()) {
|
|
44
|
+
const client = getAuthenticatedClient();
|
|
45
|
+
client.setCredentials(token);
|
|
46
|
+
const refreshedToken = await client.refreshAccessToken();
|
|
47
|
+
return refreshedToken.credentials;
|
|
48
|
+
}
|
|
49
|
+
// Use proxy for refresh
|
|
50
|
+
if (!token.refresh_token)
|
|
51
|
+
throw new Error('No refresh token available');
|
|
52
|
+
const res = await axios.post(`${AUTH_PROXY_URL}/google/token`, {
|
|
53
|
+
grant_type: 'refresh_token',
|
|
54
|
+
refresh_token: token.refresh_token,
|
|
55
|
+
});
|
|
56
|
+
return res.data;
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import clockifyConfig from '../config/clockify.js';
|
|
3
|
+
export class HttpClient {
|
|
4
|
+
constructor() {
|
|
5
|
+
this.client = axios.create({
|
|
6
|
+
baseURL: clockifyConfig.baseUrl,
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
Accept: 'application/json',
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
this.client.interceptors.request.use((config) => {
|
|
13
|
+
config.headers['X-Api-Key'] = clockifyConfig.apiKey;
|
|
14
|
+
return config;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
getClient() {
|
|
18
|
+
return this.client;
|
|
19
|
+
}
|
|
20
|
+
}
|