clocktopus 1.6.17 → 1.8.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/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'url';
9
9
  import { createRequire } from 'module';
10
10
  import { execSync } from 'child_process';
11
11
  import { completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
12
+ import { isClockifyEnabled } from './lib/credentials.js';
12
13
  import { stopJiraTimer } from './lib/jira.js';
13
14
  import { startDashboard } from './dashboard/server.js';
14
15
  import { ensureNativeAddons } from './lib/ensure-native-addons.js';
@@ -59,6 +60,20 @@ program
59
60
  .option('-j, --jira <ticket>', 'Jira ticket number')
60
61
  .option('--no-billable', 'Mark the time entry as non-billable')
61
62
  .action(async (message, options) => {
63
+ if (!isClockifyEnabled()) {
64
+ if (!options.jira) {
65
+ console.error(chalk.red('Jira-only mode requires --jira <ticket>.'));
66
+ process.exit(1);
67
+ }
68
+ const { v4: uuidv4 } = await import('uuid');
69
+ const { logSessionStart } = await import('./lib/db.js');
70
+ const sessionId = uuidv4();
71
+ const startedAt = new Date().toISOString();
72
+ const description = (message && String(message).trim()) || options.jira;
73
+ logSessionStart(sessionId, null, description, startedAt, options.jira);
74
+ console.log(chalk.green(`Timer started for ${chalk.bold(options.jira)} (Jira-only mode).`));
75
+ return;
76
+ }
62
77
  const { workspaceId } = await getWorkspaceAndUser();
63
78
  let projects = await clockify.getProjects(workspaceId);
64
79
  let localProjects = await getLocalProjects();
@@ -96,49 +111,74 @@ program
96
111
  .command('stop')
97
112
  .description('Stop the currently running time entry.')
98
113
  .action(async () => {
99
- const { workspaceId, userId } = await getWorkspaceAndUser();
100
114
  const latestSession = getLatestSession();
101
- const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
102
- if (stoppedEntry) {
103
- const completedAt = new Date().toISOString();
104
- completeLatestSession(completedAt);
105
- if (latestSession.jiraTicket) {
106
- const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
107
- if (timeSpentSeconds >= 60) {
108
- try {
109
- const worklog = await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
110
- if (worklog?.id)
111
- setSessionJiraWorklogId(latestSession.id, worklog.id);
112
- }
113
- catch (error) {
114
- console.error('Error stopping Jira timer:', error);
115
- }
116
- }
115
+ if (isClockifyEnabled()) {
116
+ const { workspaceId, userId } = await getWorkspaceAndUser();
117
+ const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
118
+ if (!stoppedEntry) {
119
+ console.log(chalk.yellow('No timer was running.'));
120
+ return;
117
121
  }
118
- console.log(chalk.red('Timer stopped.'));
119
122
  }
120
123
  else {
121
- console.log(chalk.yellow('No timer was running.'));
124
+ if (!latestSession || latestSession.completedAt) {
125
+ console.log(chalk.yellow('No timer was running.'));
126
+ return;
127
+ }
122
128
  }
129
+ const completedAt = new Date().toISOString();
130
+ completeLatestSession(completedAt);
131
+ if (latestSession?.jiraTicket) {
132
+ const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
133
+ if (timeSpentSeconds >= 60) {
134
+ try {
135
+ const worklog = await stopJiraTimer(latestSession.jiraTicket, timeSpentSeconds);
136
+ if (worklog?.id)
137
+ setSessionJiraWorklogId(latestSession.id, worklog.id);
138
+ }
139
+ catch (error) {
140
+ console.error('Error stopping Jira timer:', error);
141
+ }
142
+ }
143
+ }
144
+ console.log(chalk.red('Timer stopped.'));
123
145
  });
124
146
  program
125
147
  .command('status')
126
148
  .description('Check the status of the current timer.')
127
149
  .action(async () => {
128
- const { workspaceId, userId } = await getWorkspaceAndUser();
129
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
130
- if (activeEntry) {
131
- const startTime = new Date(activeEntry.timeInterval.start);
132
- const duration = (new Date().getTime() - startTime.getTime()) / 1000; // in seconds
133
- const hours = Math.floor(duration / 3600);
134
- const minutes = Math.floor((duration % 3600) / 60);
135
- console.log(chalk.green('🕒 A timer is currently running.'));
136
- console.log(` - ${chalk.bold('Project:')} ${activeEntry.project.name}`);
137
- console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
150
+ if (isClockifyEnabled()) {
151
+ const { workspaceId, userId } = await getWorkspaceAndUser();
152
+ const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
153
+ if (activeEntry) {
154
+ const startTime = new Date(activeEntry.timeInterval.start);
155
+ const duration = (new Date().getTime() - startTime.getTime()) / 1000;
156
+ const hours = Math.floor(duration / 3600);
157
+ const minutes = Math.floor((duration % 3600) / 60);
158
+ console.log(chalk.green('🕒 A timer is currently running.'));
159
+ console.log(` - ${chalk.bold('Project:')} ${activeEntry.project.name}`);
160
+ console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
161
+ return;
162
+ }
163
+ console.log(chalk.yellow('No timer is currently running.'));
164
+ return;
138
165
  }
139
- else {
166
+ // Jira-only mode: read from DB
167
+ const { getOpenSession } = await import('./lib/db.js');
168
+ const open = getOpenSession();
169
+ if (!open) {
140
170
  console.log(chalk.yellow('No timer is currently running.'));
171
+ return;
141
172
  }
173
+ const startTime = new Date(open.startedAt);
174
+ const duration = (new Date().getTime() - startTime.getTime()) / 1000;
175
+ const hours = Math.floor(duration / 3600);
176
+ const minutes = Math.floor((duration % 3600) / 60);
177
+ console.log(chalk.green('🕒 A timer is currently running (Jira-only mode).'));
178
+ if (open.jiraTicket)
179
+ console.log(` - ${chalk.bold('Jira:')} ${open.jiraTicket}`);
180
+ console.log(` - ${chalk.bold('Description:')} ${open.description}`);
181
+ console.log(` - ${chalk.bold('Running for:')} ${hours}h ${minutes}m`);
142
182
  });
143
183
  function sleep(ms) {
144
184
  return new Promise((res) => setTimeout(res, ms));
@@ -147,17 +187,27 @@ program
147
187
  .command('monitor:run', { hidden: true })
148
188
  .description('Run monitor in foreground (used by PM2).')
149
189
  .action(async () => {
150
- const { workspaceId, userId } = await getWorkspaceAndUser();
190
+ const creds = isClockifyEnabled() ? await getWorkspaceAndUser() : { workspaceId: '', userId: '' };
191
+ const { workspaceId, userId } = creds;
151
192
  async function stopTimerAndLog(reason) {
152
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
153
- if (!activeEntry)
154
- return false;
193
+ const clockifyOn = isClockifyEnabled();
194
+ const latestSession = getLatestSession();
195
+ if (clockifyOn) {
196
+ const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
197
+ if (!activeEntry)
198
+ return false;
199
+ }
200
+ else {
201
+ if (!latestSession || latestSession.completedAt)
202
+ return false;
203
+ }
155
204
  console.log(chalk.yellow(reason));
156
205
  const completedAt = new Date().toISOString();
157
- const latestSession = getLatestSession();
158
- const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
159
- if (!stoppedEntry)
160
- return false;
206
+ if (clockifyOn) {
207
+ const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
208
+ if (!stoppedEntry)
209
+ return false;
210
+ }
161
211
  completeLatestSession(completedAt, true);
162
212
  if (latestSession?.jiraTicket) {
163
213
  const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(latestSession.startedAt).getTime()) / 1000);
@@ -187,16 +237,30 @@ program
187
237
  const latestSession = getLatestSession();
188
238
  if (!latestSession)
189
239
  return;
190
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
191
- if (activeEntry)
192
- return;
193
240
  const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
194
- const completedAt = latestSession.completedAt ? new Date(latestSession.completedAt).getTime() : 0;
195
- const eligible = latestSession.isAutoCompleted && completedAt > twoHoursAgo && !!latestSession.projectId;
196
- if (!eligible)
241
+ const completedMs = latestSession.completedAt ? new Date(latestSession.completedAt).getTime() : 0;
242
+ if (!latestSession.isAutoCompleted || completedMs <= twoHoursAgo)
243
+ return;
244
+ if (isClockifyEnabled()) {
245
+ if (!latestSession.projectId)
246
+ return;
247
+ const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
248
+ if (activeEntry)
249
+ return;
250
+ await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
251
+ console.log(chalk.green('Timer restarted for the last used project.'));
252
+ lastResumeAt = Date.now();
253
+ return;
254
+ }
255
+ // Jira-only resume: new DB session with a fresh uuid, same ticket
256
+ if (!latestSession.jiraTicket)
197
257
  return;
198
- await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
199
- console.log(chalk.green('Timer restarted for the last used project.'));
258
+ const { v4: uuidv4 } = await import('uuid');
259
+ const { logSessionStart } = await import('./lib/db.js');
260
+ const sessionId = uuidv4();
261
+ const startedAt = new Date().toISOString();
262
+ logSessionStart(sessionId, latestSession.projectId ?? null, latestSession.description, startedAt, latestSession.jiraTicket);
263
+ console.log(chalk.green(`Resumed Jira timer for ${latestSession.jiraTicket}.`));
200
264
  lastResumeAt = Date.now();
201
265
  }
202
266
  console.log(chalk.blue('Monitoring display events (Unified Log) and idle time...'));
@@ -8,3 +8,21 @@ export function resolveCredential(key) {
8
8
  export function saveCredential(key, value) {
9
9
  setCredential(key, value);
10
10
  }
11
+ export function isClockifyKeyValid(value) {
12
+ return typeof value === 'string' && value.length > 0;
13
+ }
14
+ export function isClockifyDisabled() {
15
+ return resolveCredential('CLOCKIFY_DISABLED') === '1';
16
+ }
17
+ export function setClockifyDisabled(disabled) {
18
+ setCredential('CLOCKIFY_DISABLED', disabled ? '1' : '0');
19
+ }
20
+ export function isClockifyEnabled() {
21
+ return isClockifyKeyValid(resolveCredential('CLOCKIFY_API_KEY')) && !isClockifyDisabled();
22
+ }
23
+ export function isJiraDisabled() {
24
+ return resolveCredential('JIRA_DISABLED') === '1';
25
+ }
26
+ export function setJiraDisabled(disabled) {
27
+ setCredential('JIRA_DISABLED', disabled ? '1' : '0');
28
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { isClockifyKeyValid } from './credentials.js';
3
+ describe('isClockifyKeyValid', () => {
4
+ it('returns false when value is undefined', () => {
5
+ expect(isClockifyKeyValid(undefined)).toBe(false);
6
+ });
7
+ it('returns false when value is an empty string', () => {
8
+ expect(isClockifyKeyValid('')).toBe(false);
9
+ });
10
+ it('returns true when value is a non-empty string', () => {
11
+ expect(isClockifyKeyValid('abc123')).toBe(true);
12
+ });
13
+ });
package/dist/lib/db.js CHANGED
@@ -17,7 +17,7 @@ if (!fs.existsSync(DB_DIR)) {
17
17
  }
18
18
  const SessionSchema = z.object({
19
19
  id: z.string(),
20
- projectId: z.string(),
20
+ projectId: z.string().nullable(),
21
21
  description: z.string(),
22
22
  startedAt: z.string(),
23
23
  completedAt: z.string().nullable(),
@@ -33,7 +33,7 @@ function getDb() {
33
33
  dbInstance.exec(`
34
34
  CREATE TABLE IF NOT EXISTS sessions (
35
35
  id TEXT PRIMARY KEY,
36
- projectId TEXT NOT NULL,
36
+ projectId TEXT,
37
37
  description TEXT NOT NULL,
38
38
  startedAt TEXT NOT NULL,
39
39
  completedAt TEXT,
@@ -47,6 +47,34 @@ function getDb() {
47
47
  if (!sessionCols.some((c) => c.name === 'jiraWorklogId')) {
48
48
  dbInstance.exec('ALTER TABLE sessions ADD COLUMN jiraWorklogId TEXT');
49
49
  }
50
+ // Migration: drop NOT NULL on sessions.projectId if it was created with the old schema
51
+ const projectIdCol = sessionCols.find((c) => c.name === 'projectId');
52
+ if (projectIdCol && projectIdCol.notnull === 1) {
53
+ dbInstance.exec('BEGIN');
54
+ try {
55
+ dbInstance.exec(`
56
+ CREATE TABLE sessions_new (
57
+ id TEXT PRIMARY KEY,
58
+ projectId TEXT,
59
+ description TEXT NOT NULL,
60
+ startedAt TEXT NOT NULL,
61
+ completedAt TEXT,
62
+ isAutoCompleted INTEGER DEFAULT 0,
63
+ jiraTicket TEXT,
64
+ jiraWorklogId TEXT
65
+ )
66
+ `);
67
+ dbInstance.exec('INSERT INTO sessions_new (id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket, jiraWorklogId) ' +
68
+ 'SELECT id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket, jiraWorklogId FROM sessions');
69
+ dbInstance.exec('DROP TABLE sessions');
70
+ dbInstance.exec('ALTER TABLE sessions_new RENAME TO sessions');
71
+ dbInstance.exec('COMMIT');
72
+ }
73
+ catch (err) {
74
+ dbInstance.exec('ROLLBACK');
75
+ throw err;
76
+ }
77
+ }
50
78
  dbInstance.exec(`
51
79
  CREATE TABLE IF NOT EXISTS google_tokens (
52
80
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -113,12 +141,12 @@ export function getLatestToken() {
113
141
  export function logSessionStart(id, projectId, description, startedAt, jiraTicket) {
114
142
  const db = getDb();
115
143
  const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, ?)');
116
- stmt.run(id, projectId, description, startedAt, 0, jiraTicket ?? null);
144
+ stmt.run(id, projectId ?? null, description, startedAt, 0, jiraTicket ?? null);
117
145
  }
118
146
  export function logCompletedSession(id, projectId, description, startedAt, completedAt, jiraTicket) {
119
147
  const db = getDb();
120
148
  const stmt = db.prepare('INSERT OR IGNORE INTO sessions (id, projectId, description, startedAt, completedAt, isAutoCompleted, jiraTicket) VALUES (?, ?, ?, ?, ?, 0, ?)');
121
- stmt.run(id, projectId, description, startedAt, completedAt, jiraTicket ?? null);
149
+ stmt.run(id, projectId ?? null, description, startedAt, completedAt, jiraTicket ?? null);
122
150
  }
123
151
  export function completeLatestSession(completedAt, isAutoCompleted = false) {
124
152
  const db = getDb();
@@ -50,6 +50,11 @@ if (!startDate || !endDate) {
50
50
  process.exit(1);
51
51
  }
52
52
  async function main() {
53
+ const { isClockifyEnabled } = await import('../lib/credentials.js');
54
+ if (!isClockifyEnabled()) {
55
+ console.log('Calendar sync requires Clockify. Configure Clockify API key and re-run.');
56
+ return;
57
+ }
53
58
  const clockify = new Clockify();
54
59
  const user = await clockify.getUser();
55
60
  if (!user) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.6.17",
3
+ "version": "1.8.0",
4
4
  "description": "Time-tracking automation for Clockify with idle monitoring, Jira integration, Google Calendar sync, CLI, web dashboard, and desktop app.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",