clocktopus 1.9.0 → 1.10.1

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
@@ -1,23 +1,32 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from 'commander';
3
- import inquirer from 'inquirer';
4
3
  import chalk from 'chalk';
5
- import { Clockify } from './clockify.js';
6
4
  import * as fs from 'fs';
7
5
  import * as path from 'path';
8
6
  import { fileURLToPath } from 'url';
9
7
  import { createRequire } from 'module';
10
8
  import { execSync } from 'child_process';
11
- import { completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
9
+ import { closeStaleOpenSessions, completeLatestSession, getLatestSession, setSessionJiraWorklogId } from './lib/db.js';
12
10
  import { isClockifyEnabled } from './lib/credentials.js';
13
- import { stopJiraTimer } from './lib/jira.js';
14
- import { startDashboard } from './dashboard/server.js';
15
11
  import { ensureNativeAddons } from './lib/ensure-native-addons.js';
16
12
  import { DASHBOARD_PORT, DASHBOARD_URL } from './lib/constants.js';
17
13
  const __filename = fileURLToPath(import.meta.url);
18
14
  const __dirname = path.dirname(__filename);
19
15
  const program = new Command();
20
- const clockify = new Clockify();
16
+ // Clockify + Jira pull in axios (via follow-redirects), which in Bun triggers
17
+ // a tty WriteStream crash when loaded from a git hook context. Defer.
18
+ let _clockify = null;
19
+ async function clockify() {
20
+ if (!_clockify) {
21
+ const { Clockify } = await import('./clockify.js');
22
+ _clockify = new Clockify();
23
+ }
24
+ return _clockify;
25
+ }
26
+ async function stopJiraTimer(...args) {
27
+ const { stopJiraTimer: fn } = await import('./lib/jira.js');
28
+ return fn(...args);
29
+ }
21
30
  async function getLocalProjects() {
22
31
  const dataDir = path.join(__dirname, '../data');
23
32
  const localProjectsPath = path.join(dataDir, 'local-projects.json');
@@ -39,7 +48,7 @@ async function getLocalProjects() {
39
48
  }
40
49
  }
41
50
  async function getWorkspaceAndUser() {
42
- const user = await clockify.getUser();
51
+ const user = await (await clockify()).getUser();
43
52
  if (!user) {
44
53
  console.log(chalk.red('[index] Could not connect to Clockify. Please check your API key.'));
45
54
  process.exit(1);
@@ -72,7 +81,7 @@ program
72
81
  return;
73
82
  }
74
83
  const { workspaceId } = await getWorkspaceAndUser();
75
- let projects = await clockify.getProjects(workspaceId);
84
+ let projects = await (await clockify()).getProjects(workspaceId);
76
85
  let localProjects = await getLocalProjects();
77
86
  if (localProjects.length === 0) {
78
87
  const allProjects = projects.map((p) => ({ id: p.id, name: p.name }));
@@ -89,6 +98,7 @@ program
89
98
  console.log(chalk.yellow('No projects found in your workspace.'));
90
99
  return;
91
100
  }
101
+ const inquirer = (await import('inquirer')).default;
92
102
  const { selectedProjectId } = await inquirer.prompt([
93
103
  {
94
104
  type: 'list',
@@ -113,7 +123,7 @@ program
113
123
  const latestSession = getLatestSession();
114
124
  if (isClockifyEnabled()) {
115
125
  const { workspaceId, userId } = await getWorkspaceAndUser();
116
- const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
126
+ const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
117
127
  if (!stoppedEntry) {
118
128
  console.log(chalk.yellow('No timer was running.'));
119
129
  return;
@@ -148,7 +158,7 @@ program
148
158
  .action(async () => {
149
159
  if (isClockifyEnabled()) {
150
160
  const { workspaceId, userId } = await getWorkspaceAndUser();
151
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
161
+ const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
152
162
  if (activeEntry) {
153
163
  const startTime = new Date(activeEntry.timeInterval.start);
154
164
  const duration = (new Date().getTime() - startTime.getTime()) / 1000;
@@ -188,11 +198,28 @@ program
188
198
  .action(async () => {
189
199
  const creds = isClockifyEnabled() ? await getWorkspaceAndUser() : { workspaceId: '', userId: '' };
190
200
  const { workspaceId, userId } = creds;
201
+ // Auto-close any session left open longer than this on monitor startup,
202
+ // so a PM2 restart after a long sleep doesn't accidentally bill the
203
+ // entire gap to Jira when the next idle/lock event fires.
204
+ const MAX_OPEN_SESSION_MS = 12 * 60 * 60 * 1000; // 12h
205
+ try {
206
+ const stale = closeStaleOpenSessions(MAX_OPEN_SESSION_MS);
207
+ if (stale.length > 0) {
208
+ console.log(chalk.yellow(`Auto-closed ${stale.length} stale open session(s) older than ${MAX_OPEN_SESSION_MS / 3600000}h. ` +
209
+ `No Jira worklog was posted for these; review and log manually if needed.`));
210
+ for (const s of stale) {
211
+ console.log(chalk.gray(` - id=${s.id} jira=${s.jiraTicket ?? '-'} startedAt=${s.startedAt} closedAt=${s.completedAt}`));
212
+ }
213
+ }
214
+ }
215
+ catch (err) {
216
+ console.error(chalk.red('Failed to scan for stale open sessions:'), err);
217
+ }
191
218
  async function stopTimerAndLog(reason) {
192
219
  const clockifyOn = isClockifyEnabled();
193
220
  const latestSession = getLatestSession();
194
221
  if (clockifyOn) {
195
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
222
+ const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
196
223
  if (!activeEntry)
197
224
  return false;
198
225
  }
@@ -201,9 +228,24 @@ program
201
228
  return false;
202
229
  }
203
230
  console.log(chalk.yellow(reason));
204
- const completedAt = new Date().toISOString();
231
+ // Use idleTime to rewind end-of-work to the moment user actually went idle,
232
+ // so a weekend gap or long sleep doesn't get billed to Jira.
233
+ let idleSec = 0;
234
+ try {
235
+ const idleModule = await import('desktop-idle');
236
+ idleSec = Math.max(0, Math.floor(idleModule.default.getIdleTime() || 0));
237
+ }
238
+ catch { }
239
+ let completedMs = Date.now() - idleSec * 1000;
240
+ if (latestSession) {
241
+ const startedMs = new Date(latestSession.startedAt).getTime();
242
+ // Don't let the adjusted end fall before start; floor at start+1s.
243
+ if (completedMs < startedMs)
244
+ completedMs = startedMs + 1000;
245
+ }
246
+ const completedAt = new Date(completedMs).toISOString();
205
247
  if (clockifyOn) {
206
- const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
248
+ const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
207
249
  if (!stoppedEntry)
208
250
  return false;
209
251
  }
@@ -243,10 +285,10 @@ program
243
285
  if (isClockifyEnabled()) {
244
286
  if (!latestSession.projectId)
245
287
  return;
246
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
288
+ const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
247
289
  if (activeEntry)
248
290
  return;
249
- await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
291
+ await (await clockify()).startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
250
292
  console.log(chalk.green('Timer restarted for the last used project.'));
251
293
  lastResumeAt = Date.now();
252
294
  return;
@@ -352,7 +394,8 @@ program
352
394
  program
353
395
  .command('dash')
354
396
  .description(`Start the Clocktopus web dashboard on localhost:${DASHBOARD_PORT}.`)
355
- .action(() => {
397
+ .action(async () => {
398
+ const { startDashboard } = await import('./dashboard/server.js');
356
399
  startDashboard();
357
400
  });
358
401
  const isDev = __dirname.includes('/Projects/') || __dirname.includes('/src/');
@@ -492,7 +535,8 @@ program
492
535
  const { installHuskyHook } = await import('./lib/husky-install.js');
493
536
  const result = installHuskyHook(process.cwd());
494
537
  if (result.installed) {
495
- console.log(chalk.green(`Husky post-checkout installed at ${result.path}.`));
538
+ const verb = result.overwritten ? 'Overwrote' : 'Installed';
539
+ console.log(chalk.green(`${verb} husky post-checkout at ${result.path}.`));
496
540
  console.log(chalk.gray(' Commit it so teammates using husky get it too.'));
497
541
  return;
498
542
  }
@@ -500,10 +544,6 @@ program
500
544
  console.error(chalk.red('No .husky/ directory found. Run from the root of a husky-enabled repo.'));
501
545
  process.exit(1);
502
546
  }
503
- if (result.reason === 'already-exists') {
504
- console.log(chalk.yellow(`Existing ${result.path} left alone.`));
505
- console.log(chalk.gray(' Add this line manually: exec ~/.clocktopus/hooks/post-checkout "$@"'));
506
- }
507
547
  });
508
548
  program
509
549
  .command('hook:prompt <branch>')
@@ -516,7 +556,8 @@ program
516
556
  catch (e) {
517
557
  const msg = e instanceof Error ? e.message : String(e);
518
558
  console.error(chalk.red(`Hook prompt failed: ${msg}`));
519
- process.exit(0); // never block git checkout
520
559
  }
560
+ // Force exit — /dev/tty fs streams and the sqlite handle keep the event loop alive.
561
+ process.exit(0);
521
562
  });
522
563
  program.parse(process.argv);
package/dist/lib/db.js CHANGED
@@ -3,10 +3,17 @@ import * as path from 'path';
3
3
  import { Database } from 'bun:sqlite';
4
4
  import { z } from 'zod';
5
5
  function getDataDir() {
6
+ // Explicit override always wins (useful for tests, CI, or per-shell isolation).
7
+ const override = process.env.CLOCKTOPUS_DATA_DIR;
8
+ if (override && override.trim())
9
+ return path.resolve(override);
6
10
  const scriptDir = path.dirname(new URL(import.meta.url).pathname);
7
11
  const isDev = scriptDir.includes('/Projects/') || scriptDir.includes('/src/');
8
12
  if (isDev) {
9
- return path.join(process.cwd(), 'data/db');
13
+ // Anchor to the repo (scriptDir is <repo>/dist/lib), NOT process.cwd().
14
+ // Otherwise the git post-checkout hook, which runs in the target project's
15
+ // cwd, would spawn a stray data/ directory there.
16
+ return path.resolve(scriptDir, '..', '..', 'data', 'db');
10
17
  }
11
18
  return path.join(process.env.HOME || '~', '.clocktopus', 'data');
12
19
  }
@@ -200,6 +207,33 @@ export function getLatestSession() {
200
207
  `);
201
208
  return SessionSchema.parse(stmt.get());
202
209
  }
210
+ // Close any session that has been open longer than maxAgeMs.
211
+ // completedAt is set to min(startedAt + maxAgeMs, now - maxAgeMs) so the
212
+ // row's duration stays bounded AND completedAt is always at least maxAgeMs
213
+ // in the past — that guarantees safeRestartTimerIfNeeded's 2h-recency
214
+ // check rejects these rows and they don't get auto-resumed.
215
+ // Does NOT post to Jira; caller controls that.
216
+ // Returns the closed rows so the caller can warn or audit.
217
+ export function closeStaleOpenSessions(maxAgeMs) {
218
+ const db = getDb();
219
+ const cutoffMs = Date.now() - maxAgeMs;
220
+ const cutoffIso = new Date(cutoffMs).toISOString();
221
+ const rows = db
222
+ .prepare('SELECT id, jiraTicket, startedAt FROM sessions WHERE completedAt IS NULL AND startedAt < ?')
223
+ .all(cutoffIso);
224
+ if (rows.length === 0)
225
+ return [];
226
+ const stmt = db.prepare('UPDATE sessions SET completedAt = ?, isAutoCompleted = 1 WHERE id = ?');
227
+ const closed = [];
228
+ for (const r of rows) {
229
+ const startedMs = new Date(r.startedAt).getTime();
230
+ const completedMs = Math.min(startedMs + maxAgeMs, cutoffMs);
231
+ const completedAt = new Date(completedMs).toISOString();
232
+ stmt.run(completedAt, r.id);
233
+ closed.push({ ...r, completedAt });
234
+ }
235
+ return closed;
236
+ }
203
237
  export function deleteOldSessions(days) {
204
238
  const db = getDb();
205
239
  const date = new Date();
@@ -1,21 +1,15 @@
1
- import inquirer from 'inquirer';
2
1
  import chalk from 'chalk';
3
- import * as fs from 'fs';
4
- import * as path from 'path';
5
- import { fileURLToPath } from 'url';
6
2
  import { extractTicket } from './branch-parser.js';
7
3
  import { isRepoIgnored as realIsRepoIgnored } from './hook-ignore.js';
8
4
  import { isClockifyEnabled as realIsClockifyEnabled } from './credentials.js';
9
5
  import { getJiraSummary as realGetJiraSummary } from './jira-summary.js';
10
- import { getOpenSession as realGetOpenSession } from './db.js';
6
+ import { getOpenSession as realGetOpenSession, getActiveProjects } from './db.js';
11
7
  import { matchProjectByTicket } from './project-matcher.js';
8
+ import { simplePrompt } from './simple-prompt.js';
12
9
  import { startTimer as realStartTimer } from './start-timer.js';
13
10
  function defaultReadProjects() {
14
- const __filename = fileURLToPath(import.meta.url);
15
- const __dirname = path.dirname(__filename);
16
- const p = path.join(__dirname, '../data/local-projects.json');
17
11
  try {
18
- return JSON.parse(fs.readFileSync(p, 'utf8'));
12
+ return getActiveProjects().map((p) => ({ id: p.id, name: p.name }));
19
13
  }
20
14
  catch {
21
15
  return [];
@@ -28,7 +22,7 @@ export async function runHookPrompt(branch, opts) {
28
22
  const getJiraSummary = d.getJiraSummary ?? realGetJiraSummary;
29
23
  const getOpenSession = d.getOpenSession ?? realGetOpenSession;
30
24
  const readProjects = d.readProjects ?? defaultReadProjects;
31
- const prompt = d.prompt ?? ((qs) => inquirer.prompt(qs));
25
+ const prompt = d.prompt ?? simplePrompt;
32
26
  const startTimer = d.startTimer ?? realStartTimer;
33
27
  if (isRepoIgnored(opts.cwd)) {
34
28
  return { started: false, ticket: null, projectId: null, description: null, reason: 'ignored' };
@@ -51,7 +45,7 @@ export async function runHookPrompt(branch, opts) {
51
45
  const promptMsg = ticket
52
46
  ? `Start timer for ${chalk.bold(ticket)} (branch: ${branch})?`
53
47
  : `Start timer for branch ${chalk.bold(branch)}?`;
54
- const confirmAnswer = await prompt([{ type: 'confirm', name: 'confirmStart', message: promptMsg, default: true }]);
48
+ const confirmAnswer = await prompt([{ type: 'confirm', name: 'confirmStart', message: promptMsg, default: false }]);
55
49
  if (!confirmAnswer.confirmStart) {
56
50
  return { started: false, ticket, projectId: null, description: null, reason: 'declined' };
57
51
  }
@@ -6,8 +6,9 @@ if [ "$3" != "1" ]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
- # Require an attached tty; otherwise the prompt has nowhere to render.
10
- if ! [ -t 0 ] || ! [ -t 1 ]; then
9
+ # Require a tty on stdout; otherwise the prompt has nowhere to render.
10
+ # Git commonly redirects hook stdin, so we only check stdout here.
11
+ if ! [ -t 1 ]; then
11
12
  exit 0
12
13
  fi
13
14
 
@@ -24,6 +25,6 @@ if ! command -v clocktopus >/dev/null 2>&1; then
24
25
  exit 0
25
26
  fi
26
27
 
27
- clocktopus hook:prompt "$branch" </dev/tty >/dev/tty 2>&1 || true
28
+ NO_COLOR=1 FORCE_COLOR=0 clocktopus hook:prompt "$branch" </dev/tty || true
28
29
  exit 0
29
30
  `;
@@ -13,9 +13,7 @@ export function installHuskyHook(cwd) {
13
13
  return { installed: false, reason: 'no-husky-dir' };
14
14
  }
15
15
  const target = path.join(huskyDir, 'post-checkout');
16
- if (fs.existsSync(target)) {
17
- return { installed: false, reason: 'already-exists', path: target };
18
- }
16
+ const overwritten = fs.existsSync(target);
19
17
  fs.writeFileSync(target, huskyHookBody(), { mode: 0o755 });
20
- return { installed: true, path: target };
18
+ return { installed: true, path: target, overwritten };
21
19
  }
package/dist/lib/jira.js CHANGED
@@ -61,7 +61,20 @@ async function jiraApiRequest(endpoint, method, body) {
61
61
  return null;
62
62
  }
63
63
  }
64
+ // Hard cap so a corrupted duration (orphaned session, missed idle, etc.)
65
+ // cannot post a multi-day worklog to Jira.
66
+ export const MAX_WORKLOG_SECONDS = 12 * 60 * 60; // 12h
64
67
  export async function stopJiraTimer(ticketId, timeSpentSeconds) {
68
+ if (!Number.isFinite(timeSpentSeconds) || timeSpentSeconds <= 0) {
69
+ console.warn(`[jira] stopJiraTimer: refusing non-positive duration (${timeSpentSeconds}s) for ${ticketId}.`);
70
+ return null;
71
+ }
72
+ if (timeSpentSeconds > MAX_WORKLOG_SECONDS) {
73
+ const hours = (timeSpentSeconds / 3600).toFixed(1);
74
+ console.warn(`[jira] stopJiraTimer: refusing oversized worklog (${timeSpentSeconds}s ≈ ${hours}h) for ${ticketId}. ` +
75
+ `Cap is ${MAX_WORKLOG_SECONDS}s (${MAX_WORKLOG_SECONDS / 3600}h). Log manually via the dashboard if needed.`);
76
+ return null;
77
+ }
65
78
  const body = {
66
79
  timeSpentSeconds,
67
80
  comment: {
@@ -0,0 +1,72 @@
1
+ import * as fs from 'fs';
2
+ function readLineSync(fd) {
3
+ const buf = Buffer.alloc(1);
4
+ let line = '';
5
+ while (true) {
6
+ const n = fs.readSync(fd, buf, 0, 1, null);
7
+ if (n === 0)
8
+ return line;
9
+ const ch = buf.toString('utf8');
10
+ if (ch === '\n')
11
+ return line;
12
+ if (ch === '\r')
13
+ continue;
14
+ line += ch;
15
+ }
16
+ }
17
+ export async function simplePrompt(qs) {
18
+ let inFd;
19
+ let outFd;
20
+ try {
21
+ inFd = fs.openSync('/dev/tty', 'r');
22
+ outFd = fs.openSync('/dev/tty', 'w');
23
+ }
24
+ catch {
25
+ throw new Error('simplePrompt: cannot open /dev/tty');
26
+ }
27
+ const write = (s) => fs.writeSync(outFd, s);
28
+ const out = {};
29
+ try {
30
+ for (const raw of qs) {
31
+ const q = raw;
32
+ const label = q.message.replace(/:\s*$/, '');
33
+ if (q.type === 'confirm') {
34
+ const def = q.default !== false;
35
+ write(`${label} ${def ? '[Y/n]' : '[y/N]'}: `);
36
+ const answer = readLineSync(inFd).trim().toLowerCase();
37
+ out[q.name] = answer === '' ? def : answer === 'y' || answer === 'yes';
38
+ }
39
+ else if (q.type === 'input') {
40
+ const def = typeof q.default === 'string' ? q.default : '';
41
+ write(def ? `${label} [${def}]: ` : `${label}: `);
42
+ const answer = readLineSync(inFd).trim();
43
+ out[q.name] = answer || def;
44
+ }
45
+ else if (q.type === 'list') {
46
+ write(`${label}\n`);
47
+ const choices = q.choices ?? [];
48
+ choices.forEach((c, i) => write(` ${i + 1}) ${c.name}\n`));
49
+ while (true) {
50
+ write(`Pick 1-${choices.length}: `);
51
+ const answer = readLineSync(inFd).trim();
52
+ const n = Number.parseInt(answer, 10);
53
+ if (Number.isFinite(n) && n >= 1 && n <= choices.length) {
54
+ out[q.name] = choices[n - 1].value;
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ finally {
62
+ try {
63
+ fs.closeSync(inFd);
64
+ }
65
+ catch { }
66
+ try {
67
+ fs.closeSync(outFd);
68
+ }
69
+ catch { }
70
+ }
71
+ return out;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clocktopus",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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",