clocktopus 1.9.0 → 1.10.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
@@ -1,8 +1,6 @@
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';
@@ -10,14 +8,25 @@ import { createRequire } from 'module';
10
8
  import { execSync } from 'child_process';
11
9
  import { 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;
@@ -192,7 +202,7 @@ program
192
202
  const clockifyOn = isClockifyEnabled();
193
203
  const latestSession = getLatestSession();
194
204
  if (clockifyOn) {
195
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
205
+ const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
196
206
  if (!activeEntry)
197
207
  return false;
198
208
  }
@@ -203,7 +213,7 @@ program
203
213
  console.log(chalk.yellow(reason));
204
214
  const completedAt = new Date().toISOString();
205
215
  if (clockifyOn) {
206
- const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
216
+ const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
207
217
  if (!stoppedEntry)
208
218
  return false;
209
219
  }
@@ -243,10 +253,10 @@ program
243
253
  if (isClockifyEnabled()) {
244
254
  if (!latestSession.projectId)
245
255
  return;
246
- const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
256
+ const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
247
257
  if (activeEntry)
248
258
  return;
249
- await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
259
+ await (await clockify()).startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
250
260
  console.log(chalk.green('Timer restarted for the last used project.'));
251
261
  lastResumeAt = Date.now();
252
262
  return;
@@ -352,7 +362,8 @@ program
352
362
  program
353
363
  .command('dash')
354
364
  .description(`Start the Clocktopus web dashboard on localhost:${DASHBOARD_PORT}.`)
355
- .action(() => {
365
+ .action(async () => {
366
+ const { startDashboard } = await import('./dashboard/server.js');
356
367
  startDashboard();
357
368
  });
358
369
  const isDev = __dirname.includes('/Projects/') || __dirname.includes('/src/');
@@ -492,7 +503,8 @@ program
492
503
  const { installHuskyHook } = await import('./lib/husky-install.js');
493
504
  const result = installHuskyHook(process.cwd());
494
505
  if (result.installed) {
495
- console.log(chalk.green(`Husky post-checkout installed at ${result.path}.`));
506
+ const verb = result.overwritten ? 'Overwrote' : 'Installed';
507
+ console.log(chalk.green(`${verb} husky post-checkout at ${result.path}.`));
496
508
  console.log(chalk.gray(' Commit it so teammates using husky get it too.'));
497
509
  return;
498
510
  }
@@ -500,10 +512,6 @@ program
500
512
  console.error(chalk.red('No .husky/ directory found. Run from the root of a husky-enabled repo.'));
501
513
  process.exit(1);
502
514
  }
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
515
  });
508
516
  program
509
517
  .command('hook:prompt <branch>')
@@ -516,7 +524,8 @@ program
516
524
  catch (e) {
517
525
  const msg = e instanceof Error ? e.message : String(e);
518
526
  console.error(chalk.red(`Hook prompt failed: ${msg}`));
519
- process.exit(0); // never block git checkout
520
527
  }
528
+ // Force exit — /dev/tty fs streams and the sqlite handle keep the event loop alive.
529
+ process.exit(0);
521
530
  });
522
531
  program.parse(process.argv);
@@ -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
  }
@@ -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.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",