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.
@@ -0,0 +1,82 @@
1
+ import axios from 'axios';
2
+ import { resolveCredential } from './credentials.js';
3
+ import { getValidAccessToken } from './atlassian.js';
4
+ async function jiraApiRequest(endpoint, method, body) {
5
+ // Try OAuth first
6
+ const oauthToken = await getValidAccessToken();
7
+ if (oauthToken) {
8
+ const baseUrl = `https://api.atlassian.com/ex/jira/${oauthToken.cloud_id}/rest/api/3`;
9
+ const url = `${baseUrl}${endpoint}`;
10
+ try {
11
+ const response = await axios({
12
+ method,
13
+ url,
14
+ data: body,
15
+ headers: {
16
+ Authorization: `Bearer ${oauthToken.access_token}`,
17
+ Accept: 'application/json',
18
+ 'Content-Type': 'application/json',
19
+ },
20
+ });
21
+ return response.data;
22
+ }
23
+ catch (error) {
24
+ if (error instanceof Error) {
25
+ console.error('Error making Jira API request (OAuth):', error.message);
26
+ }
27
+ return null;
28
+ }
29
+ }
30
+ // Fall back to Basic Auth
31
+ const jiraApiUrl = resolveCredential('ATLASSIAN_URL');
32
+ const jiraApiToken = resolveCredential('ATLASSIAN_API_TOKEN');
33
+ const jiraUserEmail = resolveCredential('ATLASSIAN_EMAIL');
34
+ if (!jiraApiUrl || !jiraApiToken || !jiraUserEmail) {
35
+ console.error('Jira credentials are not configured. Use the dashboard to connect Atlassian or set env vars.');
36
+ return null;
37
+ }
38
+ try {
39
+ const response = await axios({
40
+ method,
41
+ url: `${jiraApiUrl}${endpoint}`,
42
+ data: body,
43
+ headers: {
44
+ Authorization: `Basic ${Buffer.from(`${jiraUserEmail}:${jiraApiToken}`).toString('base64')}`,
45
+ Accept: 'application/json',
46
+ 'Content-Type': 'application/json',
47
+ },
48
+ });
49
+ return response.data;
50
+ }
51
+ catch (error) {
52
+ if (error instanceof Error) {
53
+ console.error('Error making Jira API request:', error.message);
54
+ }
55
+ return null;
56
+ }
57
+ }
58
+ export async function stopJiraTimer(ticketId, timeSpentSeconds) {
59
+ const body = {
60
+ timeSpentSeconds,
61
+ comment: {
62
+ type: 'doc',
63
+ version: 1,
64
+ content: [
65
+ {
66
+ type: 'paragraph',
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: 'Timer stopped from Clocktopus',
71
+ },
72
+ ],
73
+ },
74
+ ],
75
+ },
76
+ };
77
+ console.log('Jira request body:', JSON.stringify(body, null, 2));
78
+ return await jiraApiRequest(`/issue/${ticketId}/worklog`, 'POST', body);
79
+ }
80
+ export async function getJiraTicket(ticketId) {
81
+ return await jiraApiRequest(`/issue/${ticketId}`, 'GET');
82
+ }
@@ -0,0 +1,10 @@
1
+ import { MacOSDisplayMonitor } from './display-monitor-macos.js';
2
+ import chalk from 'chalk';
3
+ export function createDisplayMonitor() {
4
+ if (process.platform === 'darwin') {
5
+ return new MacOSDisplayMonitor();
6
+ }
7
+ // Future: Add Linux and Windows implementations
8
+ console.log(chalk.yellow(`Display monitoring not yet supported on ${process.platform}. Using idle detection only.`));
9
+ return null;
10
+ }
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'child_process';
2
+ import chalk from 'chalk';
3
+ export class MacOSDisplayMonitor {
4
+ constructor() {
5
+ this.logProc = null;
6
+ this.buffer = '';
7
+ this.currentDisplayState = true; // Assume display is on at start
8
+ }
9
+ start(callback) {
10
+ const predicate = `
11
+ (subsystem == "com.apple.powermanagement" OR
12
+ category CONTAINS[c] "Display" OR
13
+ eventMessage CONTAINS[c] "Display" OR
14
+ eventMessage CONTAINS[c] "Screen" OR
15
+ eventMessage CONTAINS[c] "DarkWake" OR
16
+ eventMessage CONTAINS[c] "Wake from Sleep" OR
17
+ eventMessage CONTAINS[c] "wake reason")
18
+ `.replace(/\s+/g, ' ');
19
+ this.logProc = spawn('log', ['stream', '--style', 'syslog', '--info', '--debug', '--predicate', predicate]);
20
+ this.logProc.stdout?.setEncoding('utf8');
21
+ this.logProc.stdout?.on('data', (chunk) => {
22
+ this.buffer += chunk;
23
+ let idx;
24
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
25
+ const line = this.buffer.slice(0, idx);
26
+ this.buffer = this.buffer.slice(idx + 1);
27
+ const isOff = this.lineMatches(line, [
28
+ /\bdisplay is turned off\b/i,
29
+ /\bcom\.apple\.powermanagement\b.*\bDisplay\b.*\bOff\b/i,
30
+ /\bdisplay sleep\b/i,
31
+ ]);
32
+ const isOn = this.lineMatches(line, [
33
+ /\bdisplay is turned on\b/i,
34
+ /\bcom\.apple\.powermanagement\b.*\bDisplay\b.*\bOn\b/i,
35
+ /\bdisplay wake\b/i,
36
+ /\bwake from sleep\b/i,
37
+ /\bFullWake\b/i,
38
+ /\bShowing Display\b/i,
39
+ ]);
40
+ // Only trigger callback if state changed
41
+ if (isOff && this.currentDisplayState) {
42
+ this.currentDisplayState = false;
43
+ callback(false);
44
+ } else if (isOn && !this.currentDisplayState) {
45
+ this.currentDisplayState = true;
46
+ callback(true);
47
+ }
48
+ }
49
+ });
50
+ this.logProc.stderr?.on('data', (d) => {
51
+ console.error(`log stderr: ${d}`);
52
+ });
53
+ this.logProc.on('close', (code) => {
54
+ console.log(chalk.gray(`log stream exited with code ${code}`));
55
+ });
56
+ }
57
+ stop() {
58
+ if (this.logProc && !this.logProc.killed) {
59
+ this.logProc.kill('SIGTERM');
60
+ this.logProc = null;
61
+ }
62
+ }
63
+ lineMatches(haystack, needles) {
64
+ const s = haystack.toLowerCase();
65
+ return needles.some((n) => (typeof n === 'string' ? s.includes(n.toLowerCase()) : n.test(haystack)));
66
+ }
67
+ }
@@ -0,0 +1,33 @@
1
+ export class IdleMonitor {
2
+ constructor(thresholdSeconds = 300) {
3
+ this.thresholdSeconds = thresholdSeconds;
4
+ this.interval = null;
5
+ this.lastIdle = false;
6
+ }
7
+ async start(callback) {
8
+ this.interval = setInterval(async () => {
9
+ try {
10
+ const idleModule = await import('desktop-idle');
11
+ const idleTime = idleModule.default.getIdleTime();
12
+ const isCurrentlyIdle = idleTime >= this.thresholdSeconds;
13
+ if (isCurrentlyIdle && !this.lastIdle) {
14
+ // Transitioned to idle
15
+ callback(true);
16
+ this.lastIdle = true;
17
+ } else if (!isCurrentlyIdle && this.lastIdle) {
18
+ // Transitioned to active
19
+ callback(false);
20
+ this.lastIdle = false;
21
+ }
22
+ } catch (e) {
23
+ // Swallow errors; desktop-idle can occasionally throw on wake races
24
+ }
25
+ }, 5000);
26
+ }
27
+ stop() {
28
+ if (this.interval) {
29
+ clearInterval(this.interval);
30
+ this.interval = null;
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,37 @@
1
+ import { platform } from 'os';
2
+ import { exec } from 'child_process';
3
+ import chalk from 'chalk';
4
+ export function setupPlatformEventListeners(onLock, onUnlock) {
5
+ const osType = platform();
6
+ if (osType === 'darwin') {
7
+ const listenerPath = '/Users/admin/Projects/Personal/clockify-tracker/macos-screen-listener';
8
+ console.log(`Attempting to start macOS screen listener from: ${listenerPath}`);
9
+ // IMPORTANT: Ensure the script is executable by running: chmod +x ${listenerPath}
10
+ const childProcess = exec(listenerPath);
11
+ childProcess.on('error', (err) => {
12
+ console.error(chalk.red.bold('Failed to start macOS screen listener process:'), err);
13
+ });
14
+ childProcess.stdout?.on('data', (data) => {
15
+ const message = data.toString().trim();
16
+ console.log(chalk.yellow('macOS screen listener stdout:'), message);
17
+ try {
18
+ const msg = JSON.parse(message);
19
+ if (msg.event === 'sleep' || msg.event === 'lock') {
20
+ onLock();
21
+ } else if (msg.event === 'wake' || msg.event === 'unlock') {
22
+ onUnlock();
23
+ }
24
+ } catch (e) {
25
+ console.error('Error parsing JSON from macOS screen listener:', e);
26
+ }
27
+ });
28
+ childProcess.stderr?.on('data', (data) => {
29
+ console.error(chalk.red.bold('macOS screen listener stderr:'), data.toString());
30
+ });
31
+ childProcess.on('close', (code) => {
32
+ console.log(`macOS screen listener exited with code ${code}`);
33
+ });
34
+ } else {
35
+ // ... other OS logic
36
+ }
37
+ }
@@ -0,0 +1,56 @@
1
+ import { completeLatestSession, getLatestSession } from './db.js';
2
+ import { stopJiraTimer } from './jira.js';
3
+ import chalk from 'chalk';
4
+ export class TimerController {
5
+ constructor(clockify, workspaceId, userId) {
6
+ this.clockify = clockify;
7
+ this.workspaceId = workspaceId;
8
+ this.userId = userId;
9
+ this.lastResumeAt = 0;
10
+ this.RESUME_COOLDOWN_MS = 10000;
11
+ }
12
+ async stopTimer(reason) {
13
+ const activeEntry = await this.clockify.getActiveTimer(this.workspaceId, this.userId);
14
+ if (!activeEntry) return false;
15
+ console.log(chalk.yellow(reason));
16
+ const completedAt = new Date().toISOString();
17
+ const latestSession = getLatestSession();
18
+ const stoppedEntry = await this.clockify.stopTimer(this.workspaceId, this.userId);
19
+ if (!stoppedEntry) return false;
20
+ completeLatestSession(completedAt, true);
21
+ if (latestSession?.jiraTicket) {
22
+ const timeSpentSeconds = Math.round(
23
+ (new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000,
24
+ );
25
+ if (timeSpentSeconds >= 60) {
26
+ try {
27
+ await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
28
+ } catch (err) {
29
+ console.error('Error stopping Jira timer:', err);
30
+ }
31
+ }
32
+ }
33
+ console.log(chalk.red('Timer stopped.'));
34
+ return true;
35
+ }
36
+ async safeRestartTimer() {
37
+ const now = Date.now();
38
+ if (now - this.lastResumeAt < this.RESUME_COOLDOWN_MS) return;
39
+ // Small delay lets services settle after wake/activity
40
+ await this.sleep(800);
41
+ const latestSession = getLatestSession();
42
+ if (!latestSession) return;
43
+ const activeEntry = await this.clockify.getActiveTimer(this.workspaceId, this.userId);
44
+ if (activeEntry) return;
45
+ const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
46
+ const completedAt = latestSession.completedAt ? new Date(latestSession.completedAt).getTime() : 0;
47
+ const eligible = latestSession.isAutoCompleted && completedAt > twoHoursAgo && !!latestSession.projectId;
48
+ if (!eligible) return;
49
+ await this.clockify.startTimer(this.workspaceId, latestSession.projectId, latestSession.description);
50
+ console.log(chalk.green('Timer restarted for the last used project.'));
51
+ this.lastResumeAt = Date.now();
52
+ }
53
+ sleep(ms) {
54
+ return new Promise((resolve) => setTimeout(resolve, ms));
55
+ }
56
+ }
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
4
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
5
+ const CORS_HEADERS = {
6
+ 'Access-Control-Allow-Origin': '*',
7
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
8
+ 'Access-Control-Allow-Headers': 'Content-Type',
9
+ };
10
+ exports.default = {
11
+ async fetch(request, env) {
12
+ if (request.method === 'OPTIONS') {
13
+ return new Response(null, { headers: CORS_HEADERS });
14
+ }
15
+ const url = new URL(request.url);
16
+ // --- Atlassian ---
17
+ if (url.pathname === '/atlassian/auth-url' && request.method === 'GET') {
18
+ const redirectUri = url.searchParams.get('redirect_uri');
19
+ if (!redirectUri) {
20
+ return Response.json({ error: 'redirect_uri required' }, { status: 400, headers: CORS_HEADERS });
21
+ }
22
+ const params = new URLSearchParams({
23
+ audience: 'api.atlassian.com',
24
+ client_id: env.ATLASSIAN_CLIENT_ID,
25
+ scope: 'read:jira-work write:jira-work read:jira-user read:me offline_access',
26
+ redirect_uri: redirectUri,
27
+ response_type: 'code',
28
+ prompt: 'consent',
29
+ });
30
+ return Response.json({ url: `https://auth.atlassian.com/authorize?${params.toString()}` }, { headers: CORS_HEADERS });
31
+ }
32
+ if (url.pathname === '/atlassian/token' && request.method === 'POST') {
33
+ const body = (await request.json());
34
+ const payload = {
35
+ client_id: env.ATLASSIAN_CLIENT_ID,
36
+ client_secret: env.ATLASSIAN_CLIENT_SECRET,
37
+ grant_type: body.grant_type,
38
+ };
39
+ if (body.grant_type === 'authorization_code') {
40
+ if (!body.code || !body.redirect_uri) {
41
+ return Response.json({ error: 'code and redirect_uri required' }, { status: 400, headers: CORS_HEADERS });
42
+ }
43
+ payload.code = body.code;
44
+ payload.redirect_uri = body.redirect_uri;
45
+ }
46
+ else if (body.grant_type === 'refresh_token') {
47
+ if (!body.refresh_token) {
48
+ return Response.json({ error: 'refresh_token required' }, { status: 400, headers: CORS_HEADERS });
49
+ }
50
+ payload.refresh_token = body.refresh_token;
51
+ }
52
+ else {
53
+ return Response.json({ error: 'unsupported grant_type' }, { status: 400, headers: CORS_HEADERS });
54
+ }
55
+ const tokenRes = await fetch(ATLASSIAN_TOKEN_URL, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(payload),
59
+ });
60
+ const data = await tokenRes.text();
61
+ return new Response(data, {
62
+ status: tokenRes.status,
63
+ headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
64
+ });
65
+ }
66
+ // --- Google ---
67
+ if (url.pathname === '/google/auth-url' && request.method === 'GET') {
68
+ const redirectUri = url.searchParams.get('redirect_uri');
69
+ const scope = url.searchParams.get('scope');
70
+ if (!redirectUri || !scope) {
71
+ return Response.json({ error: 'redirect_uri and scope required' }, { status: 400, headers: CORS_HEADERS });
72
+ }
73
+ const params = new URLSearchParams({
74
+ client_id: env.GOOGLE_CLIENT_ID,
75
+ redirect_uri: redirectUri,
76
+ response_type: 'code',
77
+ scope,
78
+ access_type: 'offline',
79
+ prompt: 'consent',
80
+ });
81
+ return Response.json({ url: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` }, { headers: CORS_HEADERS });
82
+ }
83
+ if (url.pathname === '/google/token' && request.method === 'POST') {
84
+ const body = (await request.json());
85
+ const formData = new URLSearchParams();
86
+ formData.set('client_id', env.GOOGLE_CLIENT_ID);
87
+ formData.set('client_secret', env.GOOGLE_CLIENT_SECRET);
88
+ formData.set('grant_type', body.grant_type);
89
+ if (body.grant_type === 'authorization_code') {
90
+ if (!body.code || !body.redirect_uri) {
91
+ return Response.json({ error: 'code and redirect_uri required' }, { status: 400, headers: CORS_HEADERS });
92
+ }
93
+ formData.set('code', body.code);
94
+ formData.set('redirect_uri', body.redirect_uri);
95
+ }
96
+ else if (body.grant_type === 'refresh_token') {
97
+ if (!body.refresh_token) {
98
+ return Response.json({ error: 'refresh_token required' }, { status: 400, headers: CORS_HEADERS });
99
+ }
100
+ formData.set('refresh_token', body.refresh_token);
101
+ }
102
+ else {
103
+ return Response.json({ error: 'unsupported grant_type' }, { status: 400, headers: CORS_HEADERS });
104
+ }
105
+ const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
108
+ body: formData.toString(),
109
+ });
110
+ const data = await tokenRes.text();
111
+ return new Response(data, {
112
+ status: tokenRes.status,
113
+ headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' },
114
+ });
115
+ }
116
+ return Response.json({ error: 'not found' }, { status: 404, headers: CORS_HEADERS });
117
+ },
118
+ };
@@ -0,0 +1,4 @@
1
+ import { deleteOldSessions } from '../lib/db.js';
2
+ const days = process.argv[2] ? parseInt(process.argv[2], 10) : 5;
3
+ deleteOldSessions(days);
4
+ console.log(`Deleted sessions older than ${days} days.`);
@@ -0,0 +1,31 @@
1
+ import { getAuthenticatedClient } from '../lib/google.js';
2
+ import * as http from 'http';
3
+ import * as url from 'url';
4
+ import { storeToken } from '../lib/db.js';
5
+ const oAuth2Client = getAuthenticatedClient();
6
+ const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
7
+ const server = http.createServer(async (req, res) => {
8
+ if (req.url && req.url.startsWith('/oauth2callback')) {
9
+ const qs = new url.URL(req.url, 'http://localhost:3005').searchParams;
10
+ const code = qs.get('code');
11
+ if (code) {
12
+ const { tokens } = await oAuth2Client.getToken(code);
13
+ oAuth2Client.setCredentials(tokens);
14
+ storeToken(tokens);
15
+ res.end('Authentication successful! You can close this window.');
16
+ server.close();
17
+ process.exit(0);
18
+ }
19
+ }
20
+ else {
21
+ const authorizeUrl = oAuth2Client.generateAuthUrl({
22
+ access_type: 'offline',
23
+ scope: SCOPES,
24
+ });
25
+ res.writeHead(302, { Location: authorizeUrl });
26
+ res.end();
27
+ }
28
+ });
29
+ server.listen(3005, () => {
30
+ console.log('Please visit http://localhost:3005 to authorize the application.');
31
+ });
@@ -0,0 +1,141 @@
1
+ import * as dotenv from 'dotenv';
2
+ dotenv.config();
3
+ import chalk from 'chalk';
4
+ import { google } from 'googleapis';
5
+ import { getAuthenticatedClient, getRefreshedToken } from '../lib/google.js';
6
+ import { getEventProject, setEventProject, getLatestToken, storeToken } from '../lib/db.js';
7
+ import { Clockify } from '../clockify.js';
8
+ import { program } from 'commander';
9
+ import inquirer from 'inquirer';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ async function getLocalProjects() {
16
+ const dataDir = path.join(__dirname, '../../data');
17
+ const localProjectsPath = path.join(dataDir, 'local-projects.json');
18
+ try {
19
+ await fs.promises.mkdir(dataDir, { recursive: true });
20
+ try {
21
+ await fs.promises.access(localProjectsPath, fs.constants.F_OK);
22
+ }
23
+ catch {
24
+ await fs.promises.writeFile(localProjectsPath, '[]', 'utf8');
25
+ }
26
+ const data = await fs.promises.readFile(localProjectsPath, 'utf8');
27
+ return JSON.parse(data);
28
+ }
29
+ catch (_error) {
30
+ return [];
31
+ }
32
+ }
33
+ program
34
+ .option('-s, --start-date <startDate>', 'Start date for fetching calendar events')
35
+ .option('-e, --end-date <endDate>', 'End date for fetching calendar events')
36
+ .option('-t, --today', 'Log time for today')
37
+ .option('-p, --project-id <projectId>', 'Clockify project ID')
38
+ .parse(process.argv);
39
+ const opts = program.opts();
40
+ const { projectId, today } = opts;
41
+ let { startDate, endDate } = opts;
42
+ if (today) {
43
+ const todayDate = new Date();
44
+ startDate = todayDate.toISOString().split('T')[0];
45
+ endDate = startDate;
46
+ }
47
+ if (!startDate || !endDate) {
48
+ console.error('Please provide both a start and end date, or use the -t flag for today.');
49
+ process.exit(1);
50
+ }
51
+ async function main() {
52
+ const clockify = new Clockify();
53
+ const user = await clockify.getUser();
54
+ if (!user) {
55
+ console.error('Could not get Clockify user. Please check your API key.');
56
+ return;
57
+ }
58
+ const projects = await (async () => {
59
+ if (projectId) {
60
+ return [];
61
+ }
62
+ const allProjects = await clockify.getProjects(user.activeWorkspace);
63
+ const localProjects = await getLocalProjects();
64
+ if (localProjects.length > 0) {
65
+ const localProjectIds = localProjects.map((p) => p.id);
66
+ return allProjects.filter((p) => localProjectIds.includes(p.id));
67
+ }
68
+ return allProjects;
69
+ })();
70
+ let token = await getLatestToken();
71
+ if (!token) {
72
+ console.error('Please authenticate with Google first.');
73
+ return;
74
+ }
75
+ const oAuth2Client = getAuthenticatedClient();
76
+ oAuth2Client.setCredentials(token);
77
+ if (new Date(token.expiry_date) < new Date()) {
78
+ console.log('Token expired, refreshing...');
79
+ token = await getRefreshedToken(token);
80
+ storeToken(token);
81
+ oAuth2Client.setCredentials(token);
82
+ }
83
+ const calendar = google.calendar({ version: 'v3', auth: oAuth2Client });
84
+ try {
85
+ const timeMin = new Date(startDate).toISOString();
86
+ const endOfDay = new Date(endDate);
87
+ endOfDay.setDate(endOfDay.getDate() + 1);
88
+ const timeMax = endOfDay.toISOString();
89
+ const res = await calendar.events.list({
90
+ calendarId: 'primary',
91
+ timeMin: timeMin,
92
+ timeMax: timeMax,
93
+ singleEvents: true,
94
+ orderBy: 'startTime',
95
+ });
96
+ const events = res.data.items;
97
+ console.log(`Checking for events between ${startDate} and ${endDate}...`);
98
+ if (events && events.length) {
99
+ for (const event of events) {
100
+ if (event.start && event.start.date) {
101
+ console.log(`Skipping all-day event: "${event.summary}" on ${event.start.date}`);
102
+ continue;
103
+ }
104
+ if (event.summary && event.start && event.start.dateTime && event.end && event.end.dateTime) {
105
+ let eventProjectId = projectId || getEventProject(event.summary);
106
+ if (eventProjectId === null) {
107
+ console.log(`Skipping "${event.summary}" as per your previous choice.`);
108
+ continue;
109
+ }
110
+ if (!eventProjectId) {
111
+ const { selectedProjectId } = await inquirer.prompt([
112
+ {
113
+ type: 'list',
114
+ name: 'selectedProjectId',
115
+ message: `Which project for event "${event.summary}"?`,
116
+ choices: [
117
+ { name: chalk.yellow('Skip'), value: null },
118
+ ...projects.map((p) => ({ name: p.name, value: p.id })),
119
+ ],
120
+ },
121
+ ]);
122
+ eventProjectId = selectedProjectId;
123
+ setEventProject(event.summary, eventProjectId);
124
+ }
125
+ if (eventProjectId) {
126
+ console.log(`Logging "${event.summary}" to Clockify...`);
127
+ await clockify.logTime(user.activeWorkspace, eventProjectId, event.start.dateTime, event.end.dateTime, event.summary);
128
+ }
129
+ }
130
+ }
131
+ console.log('Done!');
132
+ }
133
+ else {
134
+ console.log('No upcoming events found for the specified date range.');
135
+ }
136
+ }
137
+ catch (error) {
138
+ console.error('The API returned an error: ' + error);
139
+ }
140
+ }
141
+ main();
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "clocktopus",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "clocktopus": "dist/index.js"
8
+ },
9
+ "license": "MIT",
10
+ "files": [
11
+ "dist",
12
+ "data/.gitkeep",
13
+ "!dist/desktop"
14
+ ],
15
+ "scripts": {
16
+ "build": "bunx tsc",
17
+ "prepublishOnly": "bunx tsc",
18
+ "lint": "eslint . --ext .ts",
19
+ "clock": "bun dist/index.js",
20
+ "clockd": "bunx pm2 start dist/index.js --name clocktopus --",
21
+ "monitor": "bun run clockd monitor",
22
+ "monitor:restart": "bunx pm2 restart clocktopus",
23
+ "monitor:stop": "bunx pm2 stop clocktopus",
24
+ "monitor:logs": "bunx pm2 logs clocktopus",
25
+ "monitor:status": "bunx pm2 status clocktopus",
26
+ "prepare": "husky",
27
+ "dashboard": "bun -e \"import('./dist/dashboard/server.js').then(m => m.startDashboard())\"",
28
+ "db:cleanup": "bun dist/scripts/db-cleanup.js",
29
+ "google-auth": "bunx tsc && bun dist/scripts/google-auth.js",
30
+ "log-calendar": "bunx tsc && bun -r dotenv/config dist/scripts/log-calendar-events.js"
31
+ },
32
+ "dependencies": {
33
+ "@hono/node-server": "^1.19.9",
34
+ "axios": "^1.10.0",
35
+ "chalk": "^4.1.2",
36
+ "commander": "^14.0.0",
37
+ "desktop-idle": "^1.3.0",
38
+ "dotenv": "^17.0.1",
39
+ "googleapis": "^153.0.0",
40
+ "hono": "^4.12.3",
41
+ "inquirer": "^8.2.4",
42
+ "macos-notification-state": "^3.0.0",
43
+ "node-notifier": "^10.0.1",
44
+ "uuid": "^11.1.0",
45
+ "zod": "^3.25.76"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "^1.3.11",
49
+ "@types/inquirer": "^9.0.8",
50
+ "@types/node": "^24.0.10",
51
+ "@types/node-notifier": "^8.0.5",
52
+ "@typescript-eslint/eslint-plugin": "^8.36.0",
53
+ "@typescript-eslint/parser": "^8.36.0",
54
+ "eslint": "^9.30.1",
55
+ "eslint-config-prettier": "^10.1.5",
56
+ "eslint-plugin-prettier": "^5.5.1",
57
+ "husky": "^9.1.7",
58
+ "pm2": "^6.0.8",
59
+ "prettier": "^3.6.2",
60
+ "ts-node": "^10.9.2",
61
+ "typescript": "^5.8.3",
62
+ "typescript-eslint": "^8.36.0"
63
+ }
64
+ }