clocktopus 1.8.1 → 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/README.md +38 -12
- package/dist/index.js +89 -25
- package/dist/lib/branch-parser.js +7 -0
- package/dist/lib/hook-ignore.js +16 -0
- package/dist/lib/hook-install.js +35 -0
- package/dist/lib/hook-paths.js +14 -0
- package/dist/lib/hook-prompt.js +96 -0
- package/dist/lib/hook-script.js +30 -0
- package/dist/lib/husky-install.js +19 -0
- package/dist/lib/jira-summary.js +11 -0
- package/dist/lib/project-matcher.js +14 -0
- package/dist/lib/simple-prompt.js +72 -0
- package/dist/lib/start-timer.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,18 +49,44 @@ That's it. Start/stop timers from the Home tab.
|
|
|
49
49
|
|
|
50
50
|
### Commands
|
|
51
51
|
|
|
52
|
-
| Command
|
|
53
|
-
|
|
|
54
|
-
| `clocktopus dash`
|
|
55
|
-
| `clocktopus serve`
|
|
56
|
-
| `clocktopus serve:stop`
|
|
57
|
-
| `clocktopus serve:logs`
|
|
58
|
-
| `clocktopus start`
|
|
59
|
-
| `clocktopus stop`
|
|
60
|
-
| `clocktopus status`
|
|
61
|
-
| `clocktopus monitor`
|
|
62
|
-
| `clocktopus monitor:stop`
|
|
63
|
-
| `clocktopus monitor:logs`
|
|
52
|
+
| Command | Description |
|
|
53
|
+
| ------------------------------- | ------------------------------------------ |
|
|
54
|
+
| `clocktopus dash` | Start dashboard (foreground) |
|
|
55
|
+
| `clocktopus serve` | Start dashboard as background daemon |
|
|
56
|
+
| `clocktopus serve:stop` | Stop the dashboard daemon |
|
|
57
|
+
| `clocktopus serve:logs` | View dashboard daemon logs |
|
|
58
|
+
| `clocktopus start` | Start a timer (interactive) |
|
|
59
|
+
| `clocktopus stop` | Stop the current timer |
|
|
60
|
+
| `clocktopus status` | Check timer status |
|
|
61
|
+
| `clocktopus monitor` | Start idle monitor as background daemon |
|
|
62
|
+
| `clocktopus monitor:stop` | Stop the idle monitor |
|
|
63
|
+
| `clocktopus monitor:logs` | View idle monitor logs |
|
|
64
|
+
| `clocktopus hook:install` | Install global git post-checkout hook |
|
|
65
|
+
| `clocktopus hook:uninstall` | Remove the global git post-checkout hook |
|
|
66
|
+
| `clocktopus hook:install-husky` | Add `.husky/post-checkout` in current repo |
|
|
67
|
+
|
|
68
|
+
### Git post-checkout hook
|
|
69
|
+
|
|
70
|
+
Auto-prompt to start a timer when you `git checkout` a branch. Extracts Jira tickets from branch names (e.g. `feature/RST-100-login` → `RST-100`), fetches the ticket summary from Jira as the default description, and maps to a Clockify project.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
clocktopus hook:install
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Installs a global hook at `~/.clocktopus/hooks/post-checkout` and sets `git config --global core.hooksPath` + `init.templateDir`.
|
|
77
|
+
|
|
78
|
+
**Opt out per repo:** `touch .clocktopus-ignore` at the repo root.
|
|
79
|
+
**Opt out per session:** `export CLOCKTOPUS_HOOK_DISABLE=1`.
|
|
80
|
+
|
|
81
|
+
#### Husky users
|
|
82
|
+
|
|
83
|
+
Husky sets a **local** `core.hooksPath`, which overrides the global one — so the hook won't fire in husky repos by default. Inside each husky-enabled repo, run:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
clocktopus hook:install-husky
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This writes `.husky/post-checkout` that chains to the global hook. Commit it so teammates get it too.
|
|
64
90
|
|
|
65
91
|
### Desktop App (macOS)
|
|
66
92
|
|
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
|
-
|
|
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);
|
|
@@ -60,25 +69,21 @@ program
|
|
|
60
69
|
.option('-j, --jira <ticket>', 'Jira ticket number')
|
|
61
70
|
.option('--no-billable', 'Mark the time entry as non-billable')
|
|
62
71
|
.action(async (message, options) => {
|
|
72
|
+
const { startTimer } = await import('./lib/start-timer.js');
|
|
63
73
|
if (!isClockifyEnabled()) {
|
|
64
74
|
if (!options.jira) {
|
|
65
75
|
console.error(chalk.red('Jira-only mode requires --jira <ticket>.'));
|
|
66
76
|
process.exit(1);
|
|
67
77
|
}
|
|
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
78
|
const description = (message && String(message).trim()) || options.jira;
|
|
73
|
-
|
|
79
|
+
await startTimer({ description, ticket: options.jira, projectId: null, billable: options.billable });
|
|
74
80
|
console.log(chalk.green(`Timer started for ${chalk.bold(options.jira)} (Jira-only mode).`));
|
|
75
81
|
return;
|
|
76
82
|
}
|
|
77
83
|
const { workspaceId } = await getWorkspaceAndUser();
|
|
78
|
-
let projects = await clockify.getProjects(workspaceId);
|
|
84
|
+
let projects = await (await clockify()).getProjects(workspaceId);
|
|
79
85
|
let localProjects = await getLocalProjects();
|
|
80
86
|
if (localProjects.length === 0) {
|
|
81
|
-
// If local-projects.json is empty or doesn't exist, populate it with all project IDs and names
|
|
82
87
|
const allProjects = projects.map((p) => ({ id: p.id, name: p.name }));
|
|
83
88
|
const localProjectsPath = path.join(__dirname, '../data/local-projects.json');
|
|
84
89
|
fs.writeFileSync(localProjectsPath, JSON.stringify(allProjects, null, 2), 'utf8');
|
|
@@ -93,6 +98,7 @@ program
|
|
|
93
98
|
console.log(chalk.yellow('No projects found in your workspace.'));
|
|
94
99
|
return;
|
|
95
100
|
}
|
|
101
|
+
const inquirer = (await import('inquirer')).default;
|
|
96
102
|
const { selectedProjectId } = await inquirer.prompt([
|
|
97
103
|
{
|
|
98
104
|
type: 'list',
|
|
@@ -101,11 +107,14 @@ program
|
|
|
101
107
|
choices: projects.map((p) => ({ name: p.name, value: p.id })),
|
|
102
108
|
},
|
|
103
109
|
]);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
await startTimer({
|
|
111
|
+
description: message || options.jira || '',
|
|
112
|
+
ticket: options.jira ?? null,
|
|
113
|
+
projectId: selectedProjectId,
|
|
114
|
+
billable: options.billable,
|
|
115
|
+
});
|
|
116
|
+
const projectName = projects.find((p) => p.id === selectedProjectId)?.name;
|
|
117
|
+
console.log(chalk.green(`Timer started for project: ${chalk.bold(projectName)}`));
|
|
109
118
|
});
|
|
110
119
|
program
|
|
111
120
|
.command('stop')
|
|
@@ -114,7 +123,7 @@ program
|
|
|
114
123
|
const latestSession = getLatestSession();
|
|
115
124
|
if (isClockifyEnabled()) {
|
|
116
125
|
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
117
|
-
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
126
|
+
const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
|
|
118
127
|
if (!stoppedEntry) {
|
|
119
128
|
console.log(chalk.yellow('No timer was running.'));
|
|
120
129
|
return;
|
|
@@ -149,7 +158,7 @@ program
|
|
|
149
158
|
.action(async () => {
|
|
150
159
|
if (isClockifyEnabled()) {
|
|
151
160
|
const { workspaceId, userId } = await getWorkspaceAndUser();
|
|
152
|
-
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
161
|
+
const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
|
|
153
162
|
if (activeEntry) {
|
|
154
163
|
const startTime = new Date(activeEntry.timeInterval.start);
|
|
155
164
|
const duration = (new Date().getTime() - startTime.getTime()) / 1000;
|
|
@@ -193,7 +202,7 @@ program
|
|
|
193
202
|
const clockifyOn = isClockifyEnabled();
|
|
194
203
|
const latestSession = getLatestSession();
|
|
195
204
|
if (clockifyOn) {
|
|
196
|
-
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
205
|
+
const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
|
|
197
206
|
if (!activeEntry)
|
|
198
207
|
return false;
|
|
199
208
|
}
|
|
@@ -204,7 +213,7 @@ program
|
|
|
204
213
|
console.log(chalk.yellow(reason));
|
|
205
214
|
const completedAt = new Date().toISOString();
|
|
206
215
|
if (clockifyOn) {
|
|
207
|
-
const stoppedEntry = await clockify.stopTimer(workspaceId, userId);
|
|
216
|
+
const stoppedEntry = await (await clockify()).stopTimer(workspaceId, userId);
|
|
208
217
|
if (!stoppedEntry)
|
|
209
218
|
return false;
|
|
210
219
|
}
|
|
@@ -244,10 +253,10 @@ program
|
|
|
244
253
|
if (isClockifyEnabled()) {
|
|
245
254
|
if (!latestSession.projectId)
|
|
246
255
|
return;
|
|
247
|
-
const activeEntry = await clockify.getActiveTimer(workspaceId, userId);
|
|
256
|
+
const activeEntry = await (await clockify()).getActiveTimer(workspaceId, userId);
|
|
248
257
|
if (activeEntry)
|
|
249
258
|
return;
|
|
250
|
-
await clockify.startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
|
|
259
|
+
await (await clockify()).startTimer(workspaceId, latestSession.projectId, latestSession.description, latestSession.jiraTicket ?? undefined);
|
|
251
260
|
console.log(chalk.green('Timer restarted for the last used project.'));
|
|
252
261
|
lastResumeAt = Date.now();
|
|
253
262
|
return;
|
|
@@ -353,7 +362,8 @@ program
|
|
|
353
362
|
program
|
|
354
363
|
.command('dash')
|
|
355
364
|
.description(`Start the Clocktopus web dashboard on localhost:${DASHBOARD_PORT}.`)
|
|
356
|
-
.action(() => {
|
|
365
|
+
.action(async () => {
|
|
366
|
+
const { startDashboard } = await import('./dashboard/server.js');
|
|
357
367
|
startDashboard();
|
|
358
368
|
});
|
|
359
369
|
const isDev = __dirname.includes('/Projects/') || __dirname.includes('/src/');
|
|
@@ -464,4 +474,58 @@ program
|
|
|
464
474
|
console.log(chalk.yellow('Dashboard is not running.'));
|
|
465
475
|
}
|
|
466
476
|
});
|
|
477
|
+
program
|
|
478
|
+
.command('hook:install')
|
|
479
|
+
.description('Install global git post-checkout hook (prompts to start timer on branch switch).')
|
|
480
|
+
.action(async () => {
|
|
481
|
+
const { installHook } = await import('./lib/hook-install.js');
|
|
482
|
+
await installHook();
|
|
483
|
+
console.log(chalk.green('Clocktopus post-checkout hook installed globally.'));
|
|
484
|
+
console.log(chalk.gray(' Disable per-repo: touch .clocktopus-ignore'));
|
|
485
|
+
console.log(chalk.gray(' Disable per-session: export CLOCKTOPUS_HOOK_DISABLE=1'));
|
|
486
|
+
console.log(chalk.gray(' Uninstall: clocktopus hook:uninstall'));
|
|
487
|
+
console.log();
|
|
488
|
+
console.log(chalk.yellow('Husky users: local core.hooksPath overrides global.'));
|
|
489
|
+
console.log(chalk.gray(' Inside each husky repo, run: clocktopus hook:install-husky'));
|
|
490
|
+
});
|
|
491
|
+
program
|
|
492
|
+
.command('hook:uninstall')
|
|
493
|
+
.description('Remove the global git post-checkout hook.')
|
|
494
|
+
.action(async () => {
|
|
495
|
+
const { uninstallHook } = await import('./lib/hook-install.js');
|
|
496
|
+
await uninstallHook();
|
|
497
|
+
console.log(chalk.green('Clocktopus post-checkout hook removed.'));
|
|
498
|
+
});
|
|
499
|
+
program
|
|
500
|
+
.command('hook:install-husky')
|
|
501
|
+
.description('Write a .husky/post-checkout in the current repo that chains to the global hook.')
|
|
502
|
+
.action(async () => {
|
|
503
|
+
const { installHuskyHook } = await import('./lib/husky-install.js');
|
|
504
|
+
const result = installHuskyHook(process.cwd());
|
|
505
|
+
if (result.installed) {
|
|
506
|
+
const verb = result.overwritten ? 'Overwrote' : 'Installed';
|
|
507
|
+
console.log(chalk.green(`${verb} husky post-checkout at ${result.path}.`));
|
|
508
|
+
console.log(chalk.gray(' Commit it so teammates using husky get it too.'));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (result.reason === 'no-husky-dir') {
|
|
512
|
+
console.error(chalk.red('No .husky/ directory found. Run from the root of a husky-enabled repo.'));
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
program
|
|
517
|
+
.command('hook:prompt <branch>')
|
|
518
|
+
.description('(internal) Prompt to start a timer after git checkout.')
|
|
519
|
+
.action(async (branch) => {
|
|
520
|
+
const { runHookPrompt } = await import('./lib/hook-prompt.js');
|
|
521
|
+
try {
|
|
522
|
+
await runHookPrompt(branch, { cwd: process.cwd() });
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
526
|
+
console.error(chalk.red(`Hook prompt failed: ${msg}`));
|
|
527
|
+
}
|
|
528
|
+
// Force exit — /dev/tty fs streams and the sqlite handle keep the event loop alive.
|
|
529
|
+
process.exit(0);
|
|
530
|
+
});
|
|
467
531
|
program.parse(process.argv);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
const MARKER_FILE = '.clocktopus-ignore';
|
|
4
|
+
export function isRepoIgnored(cwd) {
|
|
5
|
+
if (process.env.CLOCKTOPUS_HOOK_DISABLE === '1')
|
|
6
|
+
return true;
|
|
7
|
+
let dir = path.resolve(cwd);
|
|
8
|
+
while (true) {
|
|
9
|
+
if (fs.existsSync(path.join(dir, MARKER_FILE)))
|
|
10
|
+
return true;
|
|
11
|
+
const parent = path.dirname(dir);
|
|
12
|
+
if (parent === dir)
|
|
13
|
+
return false;
|
|
14
|
+
dir = parent;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { getHookPaths } from './hook-paths.js';
|
|
5
|
+
import { POST_CHECKOUT_SCRIPT } from './hook-script.js';
|
|
6
|
+
function writeHookScript(target) {
|
|
7
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
8
|
+
fs.writeFileSync(target, POST_CHECKOUT_SCRIPT, { mode: 0o755 });
|
|
9
|
+
}
|
|
10
|
+
export async function installHook() {
|
|
11
|
+
const p = getHookPaths();
|
|
12
|
+
writeHookScript(p.hookScript);
|
|
13
|
+
writeHookScript(p.templateHookScript);
|
|
14
|
+
execSync(`git config --global core.hooksPath "${p.hooksDir}"`, { stdio: 'ignore' });
|
|
15
|
+
execSync(`git config --global init.templateDir "${p.templateDir}"`, { stdio: 'ignore' });
|
|
16
|
+
}
|
|
17
|
+
export async function uninstallHook() {
|
|
18
|
+
const p = getHookPaths();
|
|
19
|
+
try {
|
|
20
|
+
fs.rmSync(p.hookScript, { force: true });
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
try {
|
|
24
|
+
fs.rmSync(p.templateHookScript, { force: true });
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
try {
|
|
28
|
+
execSync('git config --global --unset core.hooksPath', { stdio: 'ignore' });
|
|
29
|
+
}
|
|
30
|
+
catch { }
|
|
31
|
+
try {
|
|
32
|
+
execSync('git config --global --unset init.templateDir', { stdio: 'ignore' });
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export function getHookPaths() {
|
|
4
|
+
const rootDir = path.join(os.homedir(), '.clocktopus');
|
|
5
|
+
const hooksDir = path.join(rootDir, 'hooks');
|
|
6
|
+
const templateDir = path.join(rootDir, 'git-template');
|
|
7
|
+
return {
|
|
8
|
+
rootDir,
|
|
9
|
+
hooksDir,
|
|
10
|
+
hookScript: path.join(hooksDir, 'post-checkout'),
|
|
11
|
+
templateDir,
|
|
12
|
+
templateHookScript: path.join(templateDir, 'hooks', 'post-checkout'),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { extractTicket } from './branch-parser.js';
|
|
3
|
+
import { isRepoIgnored as realIsRepoIgnored } from './hook-ignore.js';
|
|
4
|
+
import { isClockifyEnabled as realIsClockifyEnabled } from './credentials.js';
|
|
5
|
+
import { getJiraSummary as realGetJiraSummary } from './jira-summary.js';
|
|
6
|
+
import { getOpenSession as realGetOpenSession, getActiveProjects } from './db.js';
|
|
7
|
+
import { matchProjectByTicket } from './project-matcher.js';
|
|
8
|
+
import { simplePrompt } from './simple-prompt.js';
|
|
9
|
+
import { startTimer as realStartTimer } from './start-timer.js';
|
|
10
|
+
function defaultReadProjects() {
|
|
11
|
+
try {
|
|
12
|
+
return getActiveProjects().map((p) => ({ id: p.id, name: p.name }));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function runHookPrompt(branch, opts) {
|
|
19
|
+
const d = opts.deps ?? {};
|
|
20
|
+
const isRepoIgnored = d.isRepoIgnored ?? realIsRepoIgnored;
|
|
21
|
+
const isClockifyEnabled = d.isClockifyEnabled ?? realIsClockifyEnabled;
|
|
22
|
+
const getJiraSummary = d.getJiraSummary ?? realGetJiraSummary;
|
|
23
|
+
const getOpenSession = d.getOpenSession ?? realGetOpenSession;
|
|
24
|
+
const readProjects = d.readProjects ?? defaultReadProjects;
|
|
25
|
+
const prompt = d.prompt ?? simplePrompt;
|
|
26
|
+
const startTimer = d.startTimer ?? realStartTimer;
|
|
27
|
+
if (isRepoIgnored(opts.cwd)) {
|
|
28
|
+
return { started: false, ticket: null, projectId: null, description: null, reason: 'ignored' };
|
|
29
|
+
}
|
|
30
|
+
let ticket = extractTicket(branch);
|
|
31
|
+
const openSession = getOpenSession();
|
|
32
|
+
if (openSession) {
|
|
33
|
+
const answer = await prompt([
|
|
34
|
+
{
|
|
35
|
+
type: 'confirm',
|
|
36
|
+
name: 'continueAnyway',
|
|
37
|
+
message: `A timer is already running. Stop it manually first, then re-checkout. Continue anyway?`,
|
|
38
|
+
default: false,
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
if (!answer.continueAnyway) {
|
|
42
|
+
return { started: false, ticket, projectId: null, description: null, reason: 'declined' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const promptMsg = ticket
|
|
46
|
+
? `Start timer for ${chalk.bold(ticket)} (branch: ${branch})?`
|
|
47
|
+
: `Start timer for branch ${chalk.bold(branch)}?`;
|
|
48
|
+
const confirmAnswer = await prompt([{ type: 'confirm', name: 'confirmStart', message: promptMsg, default: false }]);
|
|
49
|
+
if (!confirmAnswer.confirmStart) {
|
|
50
|
+
return { started: false, ticket, projectId: null, description: null, reason: 'declined' };
|
|
51
|
+
}
|
|
52
|
+
if (!ticket) {
|
|
53
|
+
const ticketAnswer = await prompt([{ type: 'input', name: 'ticket', message: 'Enter ticket (empty to skip):' }]);
|
|
54
|
+
const entered = typeof ticketAnswer.ticket === 'string' ? ticketAnswer.ticket : '';
|
|
55
|
+
ticket = entered.trim() ? entered.trim().toUpperCase() : null;
|
|
56
|
+
}
|
|
57
|
+
let defaultDescription = branch;
|
|
58
|
+
if (ticket) {
|
|
59
|
+
const summary = await getJiraSummary(ticket);
|
|
60
|
+
if (summary)
|
|
61
|
+
defaultDescription = summary;
|
|
62
|
+
else
|
|
63
|
+
defaultDescription = ticket;
|
|
64
|
+
}
|
|
65
|
+
const descAnswer = await prompt([
|
|
66
|
+
{ type: 'input', name: 'description', message: 'Description:', default: defaultDescription },
|
|
67
|
+
]);
|
|
68
|
+
const description = String(descAnswer.description);
|
|
69
|
+
let projectId = null;
|
|
70
|
+
if (isClockifyEnabled()) {
|
|
71
|
+
const projects = readProjects();
|
|
72
|
+
const matched = matchProjectByTicket(ticket, projects);
|
|
73
|
+
if (matched) {
|
|
74
|
+
projectId = matched.id;
|
|
75
|
+
console.log(chalk.gray(` Auto-selected project: ${matched.name}`));
|
|
76
|
+
}
|
|
77
|
+
else if (projects.length > 0) {
|
|
78
|
+
const picked = await prompt([
|
|
79
|
+
{
|
|
80
|
+
type: 'list',
|
|
81
|
+
name: 'projectId',
|
|
82
|
+
message: 'Which project?',
|
|
83
|
+
choices: projects.map((p) => ({ name: p.name, value: p.id })),
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
projectId = String(picked.projectId);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(chalk.yellow('No local projects configured. Run `clocktopus start` once to populate.'));
|
|
90
|
+
return { started: false, ticket, projectId: null, description, reason: 'no-ticket' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await startTimer({ description, ticket, projectId, billable: true });
|
|
94
|
+
console.log(chalk.green(`Timer started${ticket ? ` for ${chalk.bold(ticket)}` : ''}.`));
|
|
95
|
+
return { started: true, ticket, projectId, description };
|
|
96
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const POST_CHECKOUT_SCRIPT = `#!/bin/sh
|
|
2
|
+
# Clocktopus post-checkout hook — auto-installed by \`clocktopus hook:install\`
|
|
3
|
+
# Fires only on branch checkout (flag "1"), not on file checkout.
|
|
4
|
+
|
|
5
|
+
if [ "$3" != "1" ]; then
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
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
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# Respect user opt-out.
|
|
16
|
+
if [ "$CLOCKTOPUS_HOOK_DISABLE" = "1" ]; then
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || exit 0
|
|
21
|
+
[ -z "$branch" ] && exit 0
|
|
22
|
+
|
|
23
|
+
# Resolve clocktopus binary; silently skip if not installed globally.
|
|
24
|
+
if ! command -v clocktopus >/dev/null 2>&1; then
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
NO_COLOR=1 FORCE_COLOR=0 clocktopus hook:prompt "$branch" </dev/tty || true
|
|
29
|
+
exit 0
|
|
30
|
+
`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { getHookPaths } from './hook-paths.js';
|
|
4
|
+
export function huskyHookBody() {
|
|
5
|
+
const { hookScript } = getHookPaths();
|
|
6
|
+
return `#!/bin/sh
|
|
7
|
+
exec ${hookScript} "$@"
|
|
8
|
+
`;
|
|
9
|
+
}
|
|
10
|
+
export function installHuskyHook(cwd) {
|
|
11
|
+
const huskyDir = path.join(cwd, '.husky');
|
|
12
|
+
if (!fs.existsSync(huskyDir) || !fs.statSync(huskyDir).isDirectory()) {
|
|
13
|
+
return { installed: false, reason: 'no-husky-dir' };
|
|
14
|
+
}
|
|
15
|
+
const target = path.join(huskyDir, 'post-checkout');
|
|
16
|
+
const overwritten = fs.existsSync(target);
|
|
17
|
+
fs.writeFileSync(target, huskyHookBody(), { mode: 0o755 });
|
|
18
|
+
return { installed: true, path: target, overwritten };
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getJiraTicket } from './jira.js';
|
|
2
|
+
export async function getJiraSummary(key) {
|
|
3
|
+
try {
|
|
4
|
+
const issue = (await getJiraTicket(key));
|
|
5
|
+
const summary = issue?.fields?.summary;
|
|
6
|
+
return summary && summary.trim() ? summary.trim() : null;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function matchProjectByTicket(ticket, projects) {
|
|
2
|
+
if (!ticket)
|
|
3
|
+
return null;
|
|
4
|
+
const prefix = ticket.split('-')[0]?.toUpperCase();
|
|
5
|
+
if (!prefix)
|
|
6
|
+
return null;
|
|
7
|
+
for (const p of projects) {
|
|
8
|
+
if (!p.ticketPrefixes)
|
|
9
|
+
continue;
|
|
10
|
+
if (p.ticketPrefixes.some((tp) => tp.toUpperCase() === prefix))
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isClockifyEnabled } from './credentials.js';
|
|
2
|
+
export async function startTimer(input) {
|
|
3
|
+
if (!isClockifyEnabled()) {
|
|
4
|
+
if (!input.ticket) {
|
|
5
|
+
throw new Error('Jira-only mode: ticket required');
|
|
6
|
+
}
|
|
7
|
+
const { v4: uuidv4 } = await import('uuid');
|
|
8
|
+
const { logSessionStart } = await import('./db.js');
|
|
9
|
+
const sessionId = uuidv4();
|
|
10
|
+
const startedAt = new Date().toISOString();
|
|
11
|
+
const description = input.description?.trim() || input.ticket;
|
|
12
|
+
logSessionStart(sessionId, null, description, startedAt, input.ticket);
|
|
13
|
+
return { mode: 'jira-only', ticket: input.ticket, projectId: null, description };
|
|
14
|
+
}
|
|
15
|
+
if (!input.projectId) {
|
|
16
|
+
throw new Error('Clockify mode: projectId required');
|
|
17
|
+
}
|
|
18
|
+
const { Clockify } = await import('../clockify.js');
|
|
19
|
+
const clockify = new Clockify();
|
|
20
|
+
const user = await clockify.getUser();
|
|
21
|
+
if (!user)
|
|
22
|
+
throw new Error('Clockify auth failed');
|
|
23
|
+
await clockify.startTimer(user.defaultWorkspace, input.projectId, input.description, input.ticket ?? undefined, input.billable);
|
|
24
|
+
return {
|
|
25
|
+
mode: 'clockify',
|
|
26
|
+
ticket: input.ticket,
|
|
27
|
+
projectId: input.projectId,
|
|
28
|
+
description: input.description,
|
|
29
|
+
};
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clocktopus",
|
|
3
|
-
"version": "1.
|
|
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",
|