clocktopus 1.8.0 → 1.9.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/dashboard/routes/jira.js +2 -1
- package/dist/dashboard/views.js +62 -83
- package/dist/index.js +66 -11
- package/dist/lib/branch-parser.js +7 -0
- package/dist/lib/db.js +4 -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 +102 -0
- package/dist/lib/hook-script.js +29 -0
- package/dist/lib/husky-install.js +21 -0
- package/dist/lib/jira-summary.js +11 -0
- package/dist/lib/project-matcher.js +14 -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
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import { saveCredential, setJiraDisabled } from '../../lib/credentials.js';
|
|
4
|
-
import { storeAtlassianToken } from '../../lib/db.js';
|
|
4
|
+
import { clearAtlassianToken, storeAtlassianToken } from '../../lib/db.js';
|
|
5
5
|
import { getAtlassianAuthUrl, exchangeCodeForTokens, getAccessibleResources } from '../../lib/atlassian.js';
|
|
6
6
|
const jiraRoutes = new Hono();
|
|
7
7
|
jiraRoutes.post('/jira/enabled', async (c) => {
|
|
@@ -75,6 +75,7 @@ jiraRoutes.post('/jira', async (c) => {
|
|
|
75
75
|
saveCredential('ATLASSIAN_URL', url);
|
|
76
76
|
saveCredential('ATLASSIAN_EMAIL', email);
|
|
77
77
|
saveCredential('ATLASSIAN_API_TOKEN', token);
|
|
78
|
+
clearAtlassianToken();
|
|
78
79
|
return c.json({ ok: true, user: res.data.displayName });
|
|
79
80
|
}
|
|
80
81
|
return c.json({ ok: false, error: 'Invalid credentials.' });
|
package/dist/dashboard/views.js
CHANGED
|
@@ -212,12 +212,18 @@ export function indexPage() {
|
|
|
212
212
|
</div>
|
|
213
213
|
<div class="form-row">
|
|
214
214
|
<div>
|
|
215
|
-
<label for="manual-start">Start</label>
|
|
216
|
-
<
|
|
215
|
+
<label for="manual-start-date">Start</label>
|
|
216
|
+
<div style="display:flex; gap:0.4rem;">
|
|
217
|
+
<input type="date" id="manual-start-date" style="flex:1;" />
|
|
218
|
+
<input type="time" id="manual-start-time" style="flex:1;" />
|
|
219
|
+
</div>
|
|
217
220
|
</div>
|
|
218
221
|
<div>
|
|
219
|
-
<label for="manual-end">End</label>
|
|
220
|
-
<
|
|
222
|
+
<label for="manual-end-date">End</label>
|
|
223
|
+
<div style="display:flex; gap:0.4rem;">
|
|
224
|
+
<input type="date" id="manual-end-date" style="flex:1;" />
|
|
225
|
+
<input type="time" id="manual-end-time" style="flex:1;" />
|
|
226
|
+
</div>
|
|
221
227
|
</div>
|
|
222
228
|
</div>
|
|
223
229
|
<div class="form-row">
|
|
@@ -345,8 +351,20 @@ export function indexPage() {
|
|
|
345
351
|
<div class="dot gray" id="jira-dot"></div>
|
|
346
352
|
<h2>Jira</h2>
|
|
347
353
|
</div>
|
|
348
|
-
<p id="jira-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Connect
|
|
349
|
-
<
|
|
354
|
+
<p id="jira-desc" style="font-size:0.85rem;color:#8b949e;margin-bottom:0.5rem;">Connect Jira with an API token to log time on tickets.</p>
|
|
355
|
+
<div class="guide">
|
|
356
|
+
<ol>
|
|
357
|
+
<li>Go to <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian API Tokens</a></li>
|
|
358
|
+
<li>Click <strong>Create API token</strong> and copy it</li>
|
|
359
|
+
</ol>
|
|
360
|
+
</div>
|
|
361
|
+
<label for="jira-url">Atlassian URL</label>
|
|
362
|
+
<input type="text" id="jira-url" value="https://outsidetech.atlassian.net/rest/api/3" placeholder="https://your-org.atlassian.net/rest/api/3" />
|
|
363
|
+
<label for="jira-email">Email</label>
|
|
364
|
+
<input type="email" id="jira-email" placeholder="you@example.com" />
|
|
365
|
+
<label for="jira-token">API Token</label>
|
|
366
|
+
<input type="password" id="jira-token" placeholder="Atlassian API token" />
|
|
367
|
+
<button onclick="saveJira()">Save & Validate</button>
|
|
350
368
|
<div style="display:flex; align-items:center; gap:0.6rem; margin-top:0.75rem;">
|
|
351
369
|
<label class="toggle">
|
|
352
370
|
<input type="checkbox" id="jira-enabled-toggle" onchange="toggleJiraEnabled()" />
|
|
@@ -355,25 +373,6 @@ export function indexPage() {
|
|
|
355
373
|
<span id="jira-enabled-label" style="font-size:0.9rem; color:#8b949e;">Enabled</span>
|
|
356
374
|
</div>
|
|
357
375
|
<div class="msg" id="jira-msg"></div>
|
|
358
|
-
<div style="margin-top:1rem;">
|
|
359
|
-
<a href="#" id="jira-toggle" onclick="toggleJiraForm(event)" style="font-size:0.8rem;color:#8b949e;text-decoration:none;">or use API token ↓</a>
|
|
360
|
-
<div id="jira-form" style="display:none;margin-top:0.5rem;">
|
|
361
|
-
<div class="guide">
|
|
362
|
-
<ol>
|
|
363
|
-
<li>Go to <a href="https://id.atlassian.com/manage-profile/security/api-tokens" target="_blank">Atlassian API Tokens</a></li>
|
|
364
|
-
<li>Click <strong>Create API token</strong> and copy it</li>
|
|
365
|
-
<li>Your URL is <code>https://<your-org>.atlassian.net/rest/api/3</code></li>
|
|
366
|
-
</ol>
|
|
367
|
-
</div>
|
|
368
|
-
<label for="jira-url">Atlassian URL</label>
|
|
369
|
-
<input type="text" id="jira-url" placeholder="https://your-org.atlassian.net/rest/api/3" />
|
|
370
|
-
<label for="jira-email">Email</label>
|
|
371
|
-
<input type="email" id="jira-email" placeholder="you@example.com" />
|
|
372
|
-
<label for="jira-token">API Token</label>
|
|
373
|
-
<input type="password" id="jira-token" placeholder="Atlassian API token" />
|
|
374
|
-
<button onclick="saveJira()">Save & Validate</button>
|
|
375
|
-
</div>
|
|
376
|
-
</div>
|
|
377
376
|
</div>
|
|
378
377
|
|
|
379
378
|
</div>
|
|
@@ -600,34 +599,44 @@ export function indexPage() {
|
|
|
600
599
|
}
|
|
601
600
|
}
|
|
602
601
|
|
|
603
|
-
function
|
|
602
|
+
function toDateInputValue(date) {
|
|
604
603
|
const pad = function(n) { return String(n).padStart(2, '0'); };
|
|
605
|
-
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
|
|
606
|
-
|
|
604
|
+
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function toTimeInputValue(date) {
|
|
608
|
+
const pad = function(n) { return String(n).padStart(2, '0'); };
|
|
609
|
+
return pad(date.getHours()) + ':' + pad(date.getMinutes());
|
|
607
610
|
}
|
|
608
611
|
|
|
609
612
|
function setManualDefaults() {
|
|
610
613
|
const now = new Date();
|
|
611
614
|
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
615
|
+
const sd = document.getElementById('manual-start-date');
|
|
616
|
+
const st = document.getElementById('manual-start-time');
|
|
617
|
+
const ed = document.getElementById('manual-end-date');
|
|
618
|
+
const et = document.getElementById('manual-end-time');
|
|
619
|
+
if (sd) sd.value = toDateInputValue(hourAgo);
|
|
620
|
+
if (st) st.value = toTimeInputValue(hourAgo);
|
|
621
|
+
if (ed) ed.value = toDateInputValue(now);
|
|
622
|
+
if (et) et.value = toTimeInputValue(now);
|
|
616
623
|
}
|
|
617
624
|
|
|
618
625
|
async function logManualTime() {
|
|
619
626
|
const projectId = document.getElementById('manual-project').value;
|
|
620
|
-
const
|
|
621
|
-
const
|
|
627
|
+
const startDate = document.getElementById('manual-start-date').value;
|
|
628
|
+
const startTime = document.getElementById('manual-start-time').value;
|
|
629
|
+
const endDate = document.getElementById('manual-end-date').value;
|
|
630
|
+
const endTime = document.getElementById('manual-end-time').value;
|
|
622
631
|
const typedDescription = document.getElementById('manual-description').value.trim();
|
|
623
632
|
const jiraTicket = document.getElementById('manual-jira').value.trim();
|
|
624
633
|
const billable = document.getElementById('manual-billable').checked;
|
|
625
634
|
const description = typedDescription;
|
|
626
635
|
|
|
627
|
-
if (!
|
|
636
|
+
if (!startDate || !startTime || !endDate || !endTime) return setMsg('manual-msg', 'Please set start and end.', false);
|
|
628
637
|
|
|
629
|
-
const startMs = new Date(
|
|
630
|
-
const endMs = new Date(
|
|
638
|
+
const startMs = new Date(startDate + 'T' + startTime).getTime();
|
|
639
|
+
const endMs = new Date(endDate + 'T' + endTime).getTime();
|
|
631
640
|
if (isNaN(startMs) || isNaN(endMs)) return setMsg('manual-msg', 'Invalid date.', false);
|
|
632
641
|
if (endMs <= startMs) return setMsg('manual-msg', 'End must be after start.', false);
|
|
633
642
|
|
|
@@ -1073,6 +1082,7 @@ export function indexPage() {
|
|
|
1073
1082
|
if (data.ok) {
|
|
1074
1083
|
setMsg('clockify-msg', 'Saved and validated successfully.', true);
|
|
1075
1084
|
setDot('clockify-dot', 'green');
|
|
1085
|
+
fetchStatus();
|
|
1076
1086
|
} else {
|
|
1077
1087
|
setMsg('clockify-msg', data.error || 'Validation failed.', false);
|
|
1078
1088
|
setDot('clockify-dot', 'red');
|
|
@@ -1133,51 +1143,17 @@ export function indexPage() {
|
|
|
1133
1143
|
}
|
|
1134
1144
|
|
|
1135
1145
|
// --- Settings: Jira ---
|
|
1136
|
-
function setJiraConnected(connected
|
|
1137
|
-
const btn = document.getElementById('jira-connect-btn');
|
|
1146
|
+
function setJiraConnected(connected) {
|
|
1138
1147
|
const desc = document.getElementById('jira-desc');
|
|
1139
|
-
if (connected
|
|
1140
|
-
btn.textContent = 'Reconnect';
|
|
1141
|
-
desc.textContent = 'Connected via OAuth' + (siteUrl ? ' (' + siteUrl.replace('https://', '') + ')' : '');
|
|
1142
|
-
desc.style.color = '#3fb950';
|
|
1143
|
-
} else if (connected) {
|
|
1144
|
-
btn.textContent = 'Reconnect';
|
|
1148
|
+
if (connected) {
|
|
1145
1149
|
desc.textContent = 'Connected via API token';
|
|
1146
1150
|
desc.style.color = '#3fb950';
|
|
1147
1151
|
} else {
|
|
1148
|
-
|
|
1149
|
-
desc.textContent = 'Connect your Atlassian account to log time on Jira tickets.';
|
|
1152
|
+
desc.textContent = 'Connect Jira with an API token to log time on tickets.';
|
|
1150
1153
|
desc.style.color = '#8b949e';
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
|
|
1154
|
-
async function connectJira() {
|
|
1155
|
-
try {
|
|
1156
|
-
const res = await fetch('/api/jira/auth-url');
|
|
1157
|
-
const data = await res.json();
|
|
1158
|
-
if (data.url) {
|
|
1159
|
-
if (window.__TAURI__) {
|
|
1160
|
-
window.__TAURI__.opener.openUrl(data.url);
|
|
1161
|
-
} else {
|
|
1162
|
-
window.location.href = '/api/jira/connect';
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
} catch (e) { console.error('Connect Jira error:', e); }
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
function toggleJiraForm(e) {
|
|
1169
|
-
e.preventDefault();
|
|
1170
|
-
const form = document.getElementById('jira-form');
|
|
1171
|
-
const toggle = document.getElementById('jira-toggle');
|
|
1172
|
-
if (form.style.display === 'none') {
|
|
1173
|
-
form.style.display = 'block';
|
|
1174
|
-
toggle.innerHTML = 'hide API token form ↑';
|
|
1175
|
-
} else {
|
|
1176
|
-
form.style.display = 'none';
|
|
1177
|
-
toggle.innerHTML = 'or use API token ↓';
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
1157
|
async function toggleJiraEnabled() {
|
|
1182
1158
|
const enabled = document.getElementById('jira-enabled-toggle').checked;
|
|
1183
1159
|
document.getElementById('jira-enabled-label').textContent = enabled ? 'Enabled' : 'Disabled';
|
|
@@ -1214,6 +1190,7 @@ export function indexPage() {
|
|
|
1214
1190
|
if (data.ok) {
|
|
1215
1191
|
setMsg('jira-msg', 'Saved and validated successfully.', true);
|
|
1216
1192
|
setDot('jira-dot', 'green');
|
|
1193
|
+
fetchStatus();
|
|
1217
1194
|
} else {
|
|
1218
1195
|
setMsg('jira-msg', data.error || 'Validation failed.', false);
|
|
1219
1196
|
setDot('jira-dot', 'red');
|
|
@@ -1231,17 +1208,13 @@ export function indexPage() {
|
|
|
1231
1208
|
function renderTicketPreview(previewId, ticket, description) {
|
|
1232
1209
|
var el = document.getElementById(previewId);
|
|
1233
1210
|
if (!el) return;
|
|
1234
|
-
if (!ticket) {
|
|
1211
|
+
if (!ticket || !description) {
|
|
1235
1212
|
el.style.display = 'none';
|
|
1236
1213
|
el.innerHTML = '';
|
|
1237
1214
|
return;
|
|
1238
1215
|
}
|
|
1239
1216
|
el.style.display = '';
|
|
1240
|
-
|
|
1241
|
-
el.innerHTML = '<span class="ticket-id">' + escapeHtml(ticket) + '</span>' + escapeHtml(description);
|
|
1242
|
-
} else {
|
|
1243
|
-
el.innerHTML = '<span class="ticket-id">' + escapeHtml(ticket) + '</span><span class="ticket-hint">Could not fetch title from Jira. We will log with the ticket id.</span>';
|
|
1244
|
-
}
|
|
1217
|
+
el.innerHTML = '<span class="ticket-id">' + escapeHtml(ticket) + '</span>' + escapeHtml(description);
|
|
1245
1218
|
}
|
|
1246
1219
|
|
|
1247
1220
|
async function fetchTicketSummary(ticket) {
|
|
@@ -1413,15 +1386,21 @@ export function indexPage() {
|
|
|
1413
1386
|
|
|
1414
1387
|
const jToggle = document.getElementById('jira-enabled-toggle');
|
|
1415
1388
|
const jToggleLabel = document.getElementById('jira-enabled-label');
|
|
1416
|
-
const jConnectBtn = document.getElementById('jira-connect-btn');
|
|
1417
1389
|
const jToggleWrap = jToggle ? jToggle.closest('label').parentElement : null;
|
|
1390
|
+
const jUrl = document.getElementById('jira-url');
|
|
1391
|
+
const jEmail = document.getElementById('jira-email');
|
|
1392
|
+
const jTokenInput = document.getElementById('jira-token');
|
|
1393
|
+
const jSaveBtn = document.querySelector('button[onclick="saveJira()"]');
|
|
1418
1394
|
const jiraEnabled = !data.jiraDisabled;
|
|
1419
1395
|
if (jToggle) jToggle.checked = jiraEnabled;
|
|
1420
1396
|
if (jToggleLabel) jToggleLabel.textContent = jiraEnabled ? 'Enabled' : 'Disabled';
|
|
1421
1397
|
if (jToggleWrap) jToggleWrap.style.display = data.jiraConfigured ? '' : 'none';
|
|
1422
|
-
if (
|
|
1398
|
+
if (jUrl) jUrl.disabled = data.jiraConfigured && !jiraEnabled;
|
|
1399
|
+
if (jEmail) jEmail.disabled = data.jiraConfigured && !jiraEnabled;
|
|
1400
|
+
if (jTokenInput) jTokenInput.disabled = data.jiraConfigured && !jiraEnabled;
|
|
1401
|
+
if (jSaveBtn) jSaveBtn.disabled = data.jiraConfigured && !jiraEnabled;
|
|
1423
1402
|
setGoogleConnected(data.google, data.googleEmail);
|
|
1424
|
-
setJiraConnected(data.jira
|
|
1403
|
+
setJiraConnected(data.jira);
|
|
1425
1404
|
applyMode(data);
|
|
1426
1405
|
} catch {}
|
|
1427
1406
|
}
|
package/dist/index.js
CHANGED
|
@@ -60,17 +60,14 @@ program
|
|
|
60
60
|
.option('-j, --jira <ticket>', 'Jira ticket number')
|
|
61
61
|
.option('--no-billable', 'Mark the time entry as non-billable')
|
|
62
62
|
.action(async (message, options) => {
|
|
63
|
+
const { startTimer } = await import('./lib/start-timer.js');
|
|
63
64
|
if (!isClockifyEnabled()) {
|
|
64
65
|
if (!options.jira) {
|
|
65
66
|
console.error(chalk.red('Jira-only mode requires --jira <ticket>.'));
|
|
66
67
|
process.exit(1);
|
|
67
68
|
}
|
|
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
69
|
const description = (message && String(message).trim()) || options.jira;
|
|
73
|
-
|
|
70
|
+
await startTimer({ description, ticket: options.jira, projectId: null, billable: options.billable });
|
|
74
71
|
console.log(chalk.green(`Timer started for ${chalk.bold(options.jira)} (Jira-only mode).`));
|
|
75
72
|
return;
|
|
76
73
|
}
|
|
@@ -78,7 +75,6 @@ program
|
|
|
78
75
|
let projects = await clockify.getProjects(workspaceId);
|
|
79
76
|
let localProjects = await getLocalProjects();
|
|
80
77
|
if (localProjects.length === 0) {
|
|
81
|
-
// If local-projects.json is empty or doesn't exist, populate it with all project IDs and names
|
|
82
78
|
const allProjects = projects.map((p) => ({ id: p.id, name: p.name }));
|
|
83
79
|
const localProjectsPath = path.join(__dirname, '../data/local-projects.json');
|
|
84
80
|
fs.writeFileSync(localProjectsPath, JSON.stringify(allProjects, null, 2), 'utf8');
|
|
@@ -101,11 +97,14 @@ program
|
|
|
101
97
|
choices: projects.map((p) => ({ name: p.name, value: p.id })),
|
|
102
98
|
},
|
|
103
99
|
]);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
await startTimer({
|
|
101
|
+
description: message || options.jira || '',
|
|
102
|
+
ticket: options.jira ?? null,
|
|
103
|
+
projectId: selectedProjectId,
|
|
104
|
+
billable: options.billable,
|
|
105
|
+
});
|
|
106
|
+
const projectName = projects.find((p) => p.id === selectedProjectId)?.name;
|
|
107
|
+
console.log(chalk.green(`Timer started for project: ${chalk.bold(projectName)}`));
|
|
109
108
|
});
|
|
110
109
|
program
|
|
111
110
|
.command('stop')
|
|
@@ -464,4 +463,60 @@ program
|
|
|
464
463
|
console.log(chalk.yellow('Dashboard is not running.'));
|
|
465
464
|
}
|
|
466
465
|
});
|
|
466
|
+
program
|
|
467
|
+
.command('hook:install')
|
|
468
|
+
.description('Install global git post-checkout hook (prompts to start timer on branch switch).')
|
|
469
|
+
.action(async () => {
|
|
470
|
+
const { installHook } = await import('./lib/hook-install.js');
|
|
471
|
+
await installHook();
|
|
472
|
+
console.log(chalk.green('Clocktopus post-checkout hook installed globally.'));
|
|
473
|
+
console.log(chalk.gray(' Disable per-repo: touch .clocktopus-ignore'));
|
|
474
|
+
console.log(chalk.gray(' Disable per-session: export CLOCKTOPUS_HOOK_DISABLE=1'));
|
|
475
|
+
console.log(chalk.gray(' Uninstall: clocktopus hook:uninstall'));
|
|
476
|
+
console.log();
|
|
477
|
+
console.log(chalk.yellow('Husky users: local core.hooksPath overrides global.'));
|
|
478
|
+
console.log(chalk.gray(' Inside each husky repo, run: clocktopus hook:install-husky'));
|
|
479
|
+
});
|
|
480
|
+
program
|
|
481
|
+
.command('hook:uninstall')
|
|
482
|
+
.description('Remove the global git post-checkout hook.')
|
|
483
|
+
.action(async () => {
|
|
484
|
+
const { uninstallHook } = await import('./lib/hook-install.js');
|
|
485
|
+
await uninstallHook();
|
|
486
|
+
console.log(chalk.green('Clocktopus post-checkout hook removed.'));
|
|
487
|
+
});
|
|
488
|
+
program
|
|
489
|
+
.command('hook:install-husky')
|
|
490
|
+
.description('Write a .husky/post-checkout in the current repo that chains to the global hook.')
|
|
491
|
+
.action(async () => {
|
|
492
|
+
const { installHuskyHook } = await import('./lib/husky-install.js');
|
|
493
|
+
const result = installHuskyHook(process.cwd());
|
|
494
|
+
if (result.installed) {
|
|
495
|
+
console.log(chalk.green(`Husky post-checkout installed at ${result.path}.`));
|
|
496
|
+
console.log(chalk.gray(' Commit it so teammates using husky get it too.'));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (result.reason === 'no-husky-dir') {
|
|
500
|
+
console.error(chalk.red('No .husky/ directory found. Run from the root of a husky-enabled repo.'));
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
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
|
+
});
|
|
508
|
+
program
|
|
509
|
+
.command('hook:prompt <branch>')
|
|
510
|
+
.description('(internal) Prompt to start a timer after git checkout.')
|
|
511
|
+
.action(async (branch) => {
|
|
512
|
+
const { runHookPrompt } = await import('./lib/hook-prompt.js');
|
|
513
|
+
try {
|
|
514
|
+
await runHookPrompt(branch, { cwd: process.cwd() });
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
518
|
+
console.error(chalk.red(`Hook prompt failed: ${msg}`));
|
|
519
|
+
process.exit(0); // never block git checkout
|
|
520
|
+
}
|
|
521
|
+
});
|
|
467
522
|
program.parse(process.argv);
|
package/dist/lib/db.js
CHANGED
|
@@ -239,6 +239,10 @@ export function updateAtlassianAccessToken(access_token, expires_at) {
|
|
|
239
239
|
const stmt = db.prepare('UPDATE atlassian_tokens SET access_token = ?, expires_at = ? WHERE id = 1');
|
|
240
240
|
stmt.run(access_token, expires_at);
|
|
241
241
|
}
|
|
242
|
+
export function clearAtlassianToken() {
|
|
243
|
+
const db = getDb();
|
|
244
|
+
db.prepare('DELETE FROM atlassian_tokens WHERE id = 1').run();
|
|
245
|
+
}
|
|
242
246
|
export function upsertProjects(projects) {
|
|
243
247
|
const db = getDb();
|
|
244
248
|
const stmt = db.prepare('INSERT INTO projects (id, name, active) VALUES (?, ?, 1) ON CONFLICT(id) DO UPDATE SET name = ?');
|
|
@@ -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,102 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { extractTicket } from './branch-parser.js';
|
|
7
|
+
import { isRepoIgnored as realIsRepoIgnored } from './hook-ignore.js';
|
|
8
|
+
import { isClockifyEnabled as realIsClockifyEnabled } from './credentials.js';
|
|
9
|
+
import { getJiraSummary as realGetJiraSummary } from './jira-summary.js';
|
|
10
|
+
import { getOpenSession as realGetOpenSession } from './db.js';
|
|
11
|
+
import { matchProjectByTicket } from './project-matcher.js';
|
|
12
|
+
import { startTimer as realStartTimer } from './start-timer.js';
|
|
13
|
+
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
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function runHookPrompt(branch, opts) {
|
|
25
|
+
const d = opts.deps ?? {};
|
|
26
|
+
const isRepoIgnored = d.isRepoIgnored ?? realIsRepoIgnored;
|
|
27
|
+
const isClockifyEnabled = d.isClockifyEnabled ?? realIsClockifyEnabled;
|
|
28
|
+
const getJiraSummary = d.getJiraSummary ?? realGetJiraSummary;
|
|
29
|
+
const getOpenSession = d.getOpenSession ?? realGetOpenSession;
|
|
30
|
+
const readProjects = d.readProjects ?? defaultReadProjects;
|
|
31
|
+
const prompt = d.prompt ?? ((qs) => inquirer.prompt(qs));
|
|
32
|
+
const startTimer = d.startTimer ?? realStartTimer;
|
|
33
|
+
if (isRepoIgnored(opts.cwd)) {
|
|
34
|
+
return { started: false, ticket: null, projectId: null, description: null, reason: 'ignored' };
|
|
35
|
+
}
|
|
36
|
+
let ticket = extractTicket(branch);
|
|
37
|
+
const openSession = getOpenSession();
|
|
38
|
+
if (openSession) {
|
|
39
|
+
const answer = await prompt([
|
|
40
|
+
{
|
|
41
|
+
type: 'confirm',
|
|
42
|
+
name: 'continueAnyway',
|
|
43
|
+
message: `A timer is already running. Stop it manually first, then re-checkout. Continue anyway?`,
|
|
44
|
+
default: false,
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
if (!answer.continueAnyway) {
|
|
48
|
+
return { started: false, ticket, projectId: null, description: null, reason: 'declined' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const promptMsg = ticket
|
|
52
|
+
? `Start timer for ${chalk.bold(ticket)} (branch: ${branch})?`
|
|
53
|
+
: `Start timer for branch ${chalk.bold(branch)}?`;
|
|
54
|
+
const confirmAnswer = await prompt([{ type: 'confirm', name: 'confirmStart', message: promptMsg, default: true }]);
|
|
55
|
+
if (!confirmAnswer.confirmStart) {
|
|
56
|
+
return { started: false, ticket, projectId: null, description: null, reason: 'declined' };
|
|
57
|
+
}
|
|
58
|
+
if (!ticket) {
|
|
59
|
+
const ticketAnswer = await prompt([{ type: 'input', name: 'ticket', message: 'Enter ticket (empty to skip):' }]);
|
|
60
|
+
const entered = typeof ticketAnswer.ticket === 'string' ? ticketAnswer.ticket : '';
|
|
61
|
+
ticket = entered.trim() ? entered.trim().toUpperCase() : null;
|
|
62
|
+
}
|
|
63
|
+
let defaultDescription = branch;
|
|
64
|
+
if (ticket) {
|
|
65
|
+
const summary = await getJiraSummary(ticket);
|
|
66
|
+
if (summary)
|
|
67
|
+
defaultDescription = summary;
|
|
68
|
+
else
|
|
69
|
+
defaultDescription = ticket;
|
|
70
|
+
}
|
|
71
|
+
const descAnswer = await prompt([
|
|
72
|
+
{ type: 'input', name: 'description', message: 'Description:', default: defaultDescription },
|
|
73
|
+
]);
|
|
74
|
+
const description = String(descAnswer.description);
|
|
75
|
+
let projectId = null;
|
|
76
|
+
if (isClockifyEnabled()) {
|
|
77
|
+
const projects = readProjects();
|
|
78
|
+
const matched = matchProjectByTicket(ticket, projects);
|
|
79
|
+
if (matched) {
|
|
80
|
+
projectId = matched.id;
|
|
81
|
+
console.log(chalk.gray(` Auto-selected project: ${matched.name}`));
|
|
82
|
+
}
|
|
83
|
+
else if (projects.length > 0) {
|
|
84
|
+
const picked = await prompt([
|
|
85
|
+
{
|
|
86
|
+
type: 'list',
|
|
87
|
+
name: 'projectId',
|
|
88
|
+
message: 'Which project?',
|
|
89
|
+
choices: projects.map((p) => ({ name: p.name, value: p.id })),
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
projectId = String(picked.projectId);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(chalk.yellow('No local projects configured. Run `clocktopus start` once to populate.'));
|
|
96
|
+
return { started: false, ticket, projectId: null, description, reason: 'no-ticket' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await startTimer({ description, ticket, projectId, billable: true });
|
|
100
|
+
console.log(chalk.green(`Timer started${ticket ? ` for ${chalk.bold(ticket)}` : ''}.`));
|
|
101
|
+
return { started: true, ticket, projectId, description };
|
|
102
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 an attached tty; otherwise the prompt has nowhere to render.
|
|
10
|
+
if ! [ -t 0 ] || ! [ -t 1 ]; then
|
|
11
|
+
exit 0
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# Respect user opt-out.
|
|
15
|
+
if [ "$CLOCKTOPUS_HOOK_DISABLE" = "1" ]; then
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || exit 0
|
|
20
|
+
[ -z "$branch" ] && exit 0
|
|
21
|
+
|
|
22
|
+
# Resolve clocktopus binary; silently skip if not installed globally.
|
|
23
|
+
if ! command -v clocktopus >/dev/null 2>&1; then
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
clocktopus hook:prompt "$branch" </dev/tty >/dev/tty 2>&1 || true
|
|
28
|
+
exit 0
|
|
29
|
+
`;
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
if (fs.existsSync(target)) {
|
|
17
|
+
return { installed: false, reason: 'already-exists', path: target };
|
|
18
|
+
}
|
|
19
|
+
fs.writeFileSync(target, huskyHookBody(), { mode: 0o755 });
|
|
20
|
+
return { installed: true, path: target };
|
|
21
|
+
}
|
|
@@ -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,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.9.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",
|