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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { saveCredential } from '../../lib/credentials.js';
|
|
4
|
+
import { storeAtlassianToken } from '../../lib/db.js';
|
|
5
|
+
import { getAtlassianAuthUrl, exchangeCodeForTokens, getAccessibleResources } from '../../lib/atlassian.js';
|
|
6
|
+
const jiraRoutes = new Hono();
|
|
7
|
+
// OAuth: redirect to Atlassian authorization (browser fallback)
|
|
8
|
+
jiraRoutes.get('/jira/connect', async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const authUrl = await getAtlassianAuthUrl();
|
|
11
|
+
return c.redirect(authUrl);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : 'Failed to generate auth URL.' }, 500);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
// OAuth: return auth URL as JSON (for desktop app)
|
|
18
|
+
jiraRoutes.get('/jira/auth-url', async (c) => {
|
|
19
|
+
try {
|
|
20
|
+
const url = await getAtlassianAuthUrl();
|
|
21
|
+
return c.json({ url });
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : 'Failed to generate auth URL.' }, 500);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
// OAuth: handle callback from Atlassian
|
|
28
|
+
jiraRoutes.get('/jira/callback', async (c) => {
|
|
29
|
+
const code = c.req.query('code');
|
|
30
|
+
if (!code) {
|
|
31
|
+
return c.redirect('/?jira=error&reason=no_code');
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const tokens = await exchangeCodeForTokens(code);
|
|
35
|
+
const resources = await getAccessibleResources(tokens.access_token);
|
|
36
|
+
if (resources.length === 0) {
|
|
37
|
+
return c.redirect('/?jira=error&reason=no_sites');
|
|
38
|
+
}
|
|
39
|
+
const site = resources[0];
|
|
40
|
+
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
|
|
41
|
+
storeAtlassianToken({
|
|
42
|
+
access_token: tokens.access_token,
|
|
43
|
+
refresh_token: tokens.refresh_token,
|
|
44
|
+
expires_at: expiresAt,
|
|
45
|
+
cloud_id: site.id,
|
|
46
|
+
site_url: site.url,
|
|
47
|
+
});
|
|
48
|
+
return c.redirect(`/?jira=connected&site=${encodeURIComponent(site.name)}`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error('Atlassian OAuth callback error:', error instanceof Error ? error.message : error);
|
|
52
|
+
return c.redirect('/?jira=error&reason=token_exchange_failed');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// Basic Auth: manual API token setup (kept as fallback)
|
|
56
|
+
jiraRoutes.post('/jira', async (c) => {
|
|
57
|
+
const { url, email, token } = await c.req.json();
|
|
58
|
+
if (!url || !email || !token) {
|
|
59
|
+
return c.json({ ok: false, error: 'All fields are required.' }, 400);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const res = await axios.get(`${url}/myself`, {
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
|
|
65
|
+
Accept: 'application/json',
|
|
66
|
+
},
|
|
67
|
+
timeout: 5000,
|
|
68
|
+
});
|
|
69
|
+
if (res.status === 200) {
|
|
70
|
+
saveCredential('ATLASSIAN_URL', url);
|
|
71
|
+
saveCredential('ATLASSIAN_EMAIL', email);
|
|
72
|
+
saveCredential('ATLASSIAN_API_TOKEN', token);
|
|
73
|
+
return c.json({ ok: true, user: res.data.displayName });
|
|
74
|
+
}
|
|
75
|
+
return c.json({ ok: false, error: 'Invalid credentials.' });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return c.json({ ok: false, error: 'Could not validate credentials with Jira.' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
export default jiraRoutes;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
const monitorRoutes = new Hono();
|
|
4
|
+
function pm2Exec(command) {
|
|
5
|
+
try {
|
|
6
|
+
const output = execSync(command, { encoding: 'utf-8', timeout: 10000 });
|
|
7
|
+
return { ok: true, output: output.trim() };
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
11
|
+
return { ok: false, output: msg };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
monitorRoutes.get('/monitor/status', (c) => {
|
|
15
|
+
try {
|
|
16
|
+
const output = execSync('bunx pm2 jlist', { encoding: 'utf-8', timeout: 10000 });
|
|
17
|
+
const processes = JSON.parse(output);
|
|
18
|
+
const clocktopus = processes.find((p) => p.name === 'clocktopus');
|
|
19
|
+
if (!clocktopus) {
|
|
20
|
+
return c.json({ running: false, status: 'not found' });
|
|
21
|
+
}
|
|
22
|
+
return c.json({
|
|
23
|
+
running: clocktopus.pm2_env.status === 'online',
|
|
24
|
+
status: clocktopus.pm2_env.status,
|
|
25
|
+
uptime: clocktopus.pm2_env.pm_uptime,
|
|
26
|
+
restarts: clocktopus.pm2_env.restart_time,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return c.json({ running: false, status: 'pm2 not available' });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
monitorRoutes.post('/monitor/start', (c) => {
|
|
34
|
+
const result = pm2Exec('bunx pm2 start dist/index.js --name clocktopus -- monitor');
|
|
35
|
+
return c.json(result);
|
|
36
|
+
});
|
|
37
|
+
monitorRoutes.post('/monitor/stop', (c) => {
|
|
38
|
+
const result = pm2Exec('bunx pm2 stop clocktopus');
|
|
39
|
+
return c.json(result);
|
|
40
|
+
});
|
|
41
|
+
monitorRoutes.post('/monitor/restart', (c) => {
|
|
42
|
+
const result = pm2Exec('bunx pm2 restart clocktopus');
|
|
43
|
+
return c.json(result);
|
|
44
|
+
});
|
|
45
|
+
export default monitorRoutes;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { resolveCredential } from '../../lib/credentials.js';
|
|
4
|
+
import { getLatestToken, getAtlassianToken } from '../../lib/db.js';
|
|
5
|
+
import { getValidAccessToken } from '../../lib/atlassian.js';
|
|
6
|
+
const statusRoutes = new Hono();
|
|
7
|
+
statusRoutes.get('/status', async (c) => {
|
|
8
|
+
const results = {
|
|
9
|
+
clockify: false,
|
|
10
|
+
google: false,
|
|
11
|
+
jira: false,
|
|
12
|
+
jiraOAuth: false,
|
|
13
|
+
};
|
|
14
|
+
// Check Clockify
|
|
15
|
+
const clockifyKey = resolveCredential('CLOCKIFY_API_KEY');
|
|
16
|
+
if (clockifyKey) {
|
|
17
|
+
results.clockifyKeyHint = '***' + clockifyKey.slice(-4);
|
|
18
|
+
try {
|
|
19
|
+
const res = await axios.get('https://api.clockify.me/api/v1/user', {
|
|
20
|
+
headers: { 'X-Api-Key': clockifyKey },
|
|
21
|
+
timeout: 5000,
|
|
22
|
+
});
|
|
23
|
+
results.clockify = res.status === 200;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
// Check Google — token exists in DB
|
|
28
|
+
const token = getLatestToken();
|
|
29
|
+
results.google = !!token;
|
|
30
|
+
if (token) {
|
|
31
|
+
const googleEmail = resolveCredential('GOOGLE_ACCOUNT_EMAIL');
|
|
32
|
+
if (googleEmail)
|
|
33
|
+
results.googleEmail = googleEmail;
|
|
34
|
+
}
|
|
35
|
+
// Check Jira — OAuth first, then Basic Auth
|
|
36
|
+
const storedAtlassianToken = getAtlassianToken();
|
|
37
|
+
if (storedAtlassianToken) {
|
|
38
|
+
results.jiraOAuth = true;
|
|
39
|
+
if (storedAtlassianToken.site_url)
|
|
40
|
+
results.jiraSiteUrl = storedAtlassianToken.site_url;
|
|
41
|
+
try {
|
|
42
|
+
const validToken = await getValidAccessToken();
|
|
43
|
+
if (validToken) {
|
|
44
|
+
const res = await axios.get(`https://api.atlassian.com/ex/jira/${validToken.cloud_id}/rest/api/3/myself`, {
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${validToken.access_token}`,
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
},
|
|
49
|
+
timeout: 5000,
|
|
50
|
+
});
|
|
51
|
+
results.jira = res.status === 200;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (axios.isAxiosError(error)) {
|
|
56
|
+
console.error('Jira OAuth status check failed:', error.response?.status, error.response?.data);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.error('Jira OAuth status check failed:', error);
|
|
60
|
+
}
|
|
61
|
+
results.jira = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const jiraUrl = resolveCredential('ATLASSIAN_URL');
|
|
66
|
+
const jiraToken = resolveCredential('ATLASSIAN_API_TOKEN');
|
|
67
|
+
const jiraEmail = resolveCredential('ATLASSIAN_EMAIL');
|
|
68
|
+
if (jiraUrl && jiraToken && jiraEmail) {
|
|
69
|
+
try {
|
|
70
|
+
const res = await axios.get(`${jiraUrl}/myself`, {
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Basic ${Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64')}`,
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
},
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
});
|
|
77
|
+
results.jira = res.status === 200;
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return c.json(results);
|
|
83
|
+
});
|
|
84
|
+
export default statusRoutes;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { Clockify } from '../../clockify.js';
|
|
3
|
+
import { completeLatestSession } from '../../lib/db.js';
|
|
4
|
+
const timerRoutes = new Hono();
|
|
5
|
+
timerRoutes.get('/timer/active', async (c) => {
|
|
6
|
+
try {
|
|
7
|
+
const clockify = new Clockify();
|
|
8
|
+
const user = await clockify.getUser();
|
|
9
|
+
if (!user)
|
|
10
|
+
return c.json({ active: false });
|
|
11
|
+
const timer = await clockify.getActiveTimer(user.defaultWorkspace, user.id);
|
|
12
|
+
if (!timer)
|
|
13
|
+
return c.json({ active: false });
|
|
14
|
+
return c.json({
|
|
15
|
+
active: true,
|
|
16
|
+
description: timer.description,
|
|
17
|
+
projectId: timer.projectId,
|
|
18
|
+
start: timer.timeInterval.start,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return c.json({ active: false });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
timerRoutes.post('/timer/start', async (c) => {
|
|
26
|
+
const { projectId, description, jiraTicket } = await c.req.json();
|
|
27
|
+
if (!projectId || !description) {
|
|
28
|
+
return c.json({ ok: false, error: 'Project and description are required.' }, 400);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const clockify = new Clockify();
|
|
32
|
+
const user = await clockify.getUser();
|
|
33
|
+
if (!user)
|
|
34
|
+
return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
|
|
35
|
+
const result = await clockify.startTimer(user.defaultWorkspace, projectId, description, jiraTicket);
|
|
36
|
+
if (!result)
|
|
37
|
+
return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
|
|
38
|
+
return c.json({ ok: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
timerRoutes.post('/timer/stop', async (c) => {
|
|
45
|
+
try {
|
|
46
|
+
const clockify = new Clockify();
|
|
47
|
+
const user = await clockify.getUser();
|
|
48
|
+
if (!user)
|
|
49
|
+
return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
|
|
50
|
+
const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
|
|
51
|
+
if (!result)
|
|
52
|
+
return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
|
|
53
|
+
completeLatestSession(new Date().toISOString(), false);
|
|
54
|
+
return c.json({ ok: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
export default timerRoutes;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { indexPage } from './views.js';
|
|
4
|
+
import statusRoutes from './routes/status.js';
|
|
5
|
+
import clockifyRoutes from './routes/clockify.js';
|
|
6
|
+
import jiraRoutes from './routes/jira.js';
|
|
7
|
+
import googleRoutes from './routes/google.js';
|
|
8
|
+
import timerRoutes from './routes/timer.js';
|
|
9
|
+
import dataRoutes from './routes/data.js';
|
|
10
|
+
import monitorRoutes from './routes/monitor.js';
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
app.get('/', (c) => c.html(indexPage()));
|
|
13
|
+
app.route('/api', statusRoutes);
|
|
14
|
+
app.route('/api', clockifyRoutes);
|
|
15
|
+
app.route('/api', jiraRoutes);
|
|
16
|
+
app.route('/api', googleRoutes);
|
|
17
|
+
app.route('/api', timerRoutes);
|
|
18
|
+
app.route('/api', dataRoutes);
|
|
19
|
+
app.route('/api', monitorRoutes);
|
|
20
|
+
export function startDashboard() {
|
|
21
|
+
const port = 4001;
|
|
22
|
+
console.log(`Clocktopus dashboard running at http://localhost:${port}`);
|
|
23
|
+
serve({ fetch: app.fetch, port });
|
|
24
|
+
}
|