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 +63 -22
- package/dist/lib/db.js +35 -1
- package/dist/lib/hook-prompt.js +5 -11
- package/dist/lib/hook-script.js +4 -3
- package/dist/lib/husky-install.js +2 -4
- package/dist/lib/jira.js +13 -0
- package/dist/lib/simple-prompt.js +72 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/lib/hook-prompt.js
CHANGED
|
@@ -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
|
|
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 ??
|
|
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:
|
|
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
|
}
|
package/dist/lib/hook-script.js
CHANGED
|
@@ -6,8 +6,9 @@ if [ "$3" != "1" ]; then
|
|
|
6
6
|
exit 0
|
|
7
7
|
fi
|
|
8
8
|
|
|
9
|
-
# Require
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|