@timmy6942025/cli-timer 1.0.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/src/index.js ADDED
@@ -0,0 +1,791 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { spawnSync } = require("child_process");
5
+ const figlet = require("figlet");
6
+
7
+ const PROJECT_ROOT = path.join(__dirname, "..");
8
+ const CONFIG_DIR = path.join(os.homedir(), ".cli-timer");
9
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
10
+ const SETTINGS_STATE_PATH = path.join(CONFIG_DIR, "settings-state.json");
11
+ const DEFAULT_FONT = "Standard";
12
+ const TIMER_SAMPLE_TEXT = "00:00:00";
13
+
14
+ const MIN_TICK_RATE_MS = 50;
15
+ const MAX_TICK_RATE_MS = 1000;
16
+
17
+ const DEFAULT_KEYBINDINGS = Object.freeze({
18
+ pauseKey: "p",
19
+ pauseAltKey: "space",
20
+ restartKey: "r",
21
+ exitKey: "q",
22
+ exitAltKey: "e"
23
+ });
24
+
25
+ const LEGACY_DEFAULT_KEYBINDINGS = Object.freeze({
26
+ pauseKey: "p",
27
+ pauseAltKey: "space",
28
+ restartKey: "r",
29
+ exitKey: "s",
30
+ exitAltKey: "e"
31
+ });
32
+
33
+ const DEFAULT_CONFIG = Object.freeze({
34
+ font: DEFAULT_FONT,
35
+ centerDisplay: true,
36
+ showHeader: true,
37
+ showControls: true,
38
+ tickRateMs: 100,
39
+ completionMessage: "Time is up!",
40
+ keybindings: { ...DEFAULT_KEYBINDINGS }
41
+ });
42
+
43
+ let allFontsCache = null;
44
+ let compatibleFontsSlowCache = null;
45
+
46
+ function clearScreen() {
47
+ process.stdout.write("\x1b[2J\x1b[H");
48
+ }
49
+
50
+ function hideCursor() {
51
+ process.stdout.write("\x1b[?25l");
52
+ }
53
+
54
+ function showCursor() {
55
+ process.stdout.write("\x1b[?25h");
56
+ }
57
+
58
+ function formatHms(totalSeconds) {
59
+ const hours = Math.floor(totalSeconds / 3600);
60
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
61
+ const seconds = totalSeconds % 60;
62
+ return [hours, minutes, seconds].map((n) => String(n).padStart(2, "0")).join(":");
63
+ }
64
+
65
+ function getAllFonts() {
66
+ if (allFontsCache) {
67
+ return allFontsCache;
68
+ }
69
+
70
+ try {
71
+ allFontsCache = figlet.fontsSync().slice().sort((a, b) => a.localeCompare(b));
72
+ } catch (_error) {
73
+ allFontsCache = [DEFAULT_FONT];
74
+ }
75
+
76
+ return allFontsCache;
77
+ }
78
+
79
+ function hasVisibleGlyphs(text) {
80
+ return typeof text === "string" && /[^\s]/.test(text);
81
+ }
82
+
83
+ function renderWithFont(text, fontName) {
84
+ try {
85
+ return figlet.textSync(text, {
86
+ font: fontName,
87
+ horizontalLayout: "fitted",
88
+ verticalLayout: "default",
89
+ width: process.stdout.columns || 120,
90
+ whitespaceBreak: true
91
+ });
92
+ } catch (_error) {
93
+ return "";
94
+ }
95
+ }
96
+
97
+ function isTimerCompatibleFont(fontName) {
98
+ return hasVisibleGlyphs(renderWithFont(TIMER_SAMPLE_TEXT, fontName));
99
+ }
100
+
101
+ function getTimerCompatibleFontsSlow() {
102
+ if (compatibleFontsSlowCache) {
103
+ return compatibleFontsSlowCache;
104
+ }
105
+
106
+ const fonts = getAllFonts();
107
+ compatibleFontsSlowCache = fonts.filter((fontName) => isTimerCompatibleFont(fontName));
108
+ if (compatibleFontsSlowCache.length === 0) {
109
+ compatibleFontsSlowCache = [DEFAULT_FONT];
110
+ }
111
+ return compatibleFontsSlowCache;
112
+ }
113
+
114
+ function normalizeFontName(input) {
115
+ const fonts = getAllFonts();
116
+ const exact = fonts.find((name) => name === input);
117
+ if (exact) {
118
+ return exact;
119
+ }
120
+ const lowerInput = input.toLowerCase();
121
+ return fonts.find((name) => name.toLowerCase() === lowerInput) || null;
122
+ }
123
+
124
+ function sanitizeTickRate(raw) {
125
+ if (!Number.isFinite(raw)) {
126
+ return DEFAULT_CONFIG.tickRateMs;
127
+ }
128
+
129
+ const value = Math.floor(raw);
130
+ if (value < MIN_TICK_RATE_MS) {
131
+ return MIN_TICK_RATE_MS;
132
+ }
133
+ if (value > MAX_TICK_RATE_MS) {
134
+ return MAX_TICK_RATE_MS;
135
+ }
136
+ return value;
137
+ }
138
+
139
+ function normalizeCompletionMessage(raw) {
140
+ if (typeof raw !== "string") {
141
+ return DEFAULT_CONFIG.completionMessage;
142
+ }
143
+
144
+ return raw.replace(/\r/g, "").replace(/\n/g, " ").slice(0, 240);
145
+ }
146
+
147
+ function normalizeKeyToken(raw, fallback) {
148
+ if (typeof raw !== "string") {
149
+ return fallback;
150
+ }
151
+
152
+ const value = raw.trim().toLowerCase();
153
+ if (value === "space") {
154
+ return "space";
155
+ }
156
+
157
+ if (/^[!-~]$/.test(value)) {
158
+ return value;
159
+ }
160
+
161
+ return fallback;
162
+ }
163
+
164
+ function normalizeKeybindings(raw) {
165
+ const next = { ...DEFAULT_KEYBINDINGS };
166
+ if (!raw || typeof raw !== "object") {
167
+ return next;
168
+ }
169
+
170
+ next.pauseKey = normalizeKeyToken(raw.pauseKey, next.pauseKey);
171
+ next.pauseAltKey = normalizeKeyToken(raw.pauseAltKey, next.pauseAltKey);
172
+ next.restartKey = normalizeKeyToken(raw.restartKey, next.restartKey);
173
+ next.exitKey = normalizeKeyToken(raw.exitKey, next.exitKey);
174
+ next.exitAltKey = normalizeKeyToken(raw.exitAltKey, next.exitAltKey);
175
+
176
+ if (
177
+ next.pauseKey === LEGACY_DEFAULT_KEYBINDINGS.pauseKey &&
178
+ next.pauseAltKey === LEGACY_DEFAULT_KEYBINDINGS.pauseAltKey &&
179
+ next.restartKey === LEGACY_DEFAULT_KEYBINDINGS.restartKey &&
180
+ next.exitKey === LEGACY_DEFAULT_KEYBINDINGS.exitKey &&
181
+ next.exitAltKey === LEGACY_DEFAULT_KEYBINDINGS.exitAltKey
182
+ ) {
183
+ return { ...DEFAULT_KEYBINDINGS };
184
+ }
185
+
186
+ return next;
187
+ }
188
+
189
+ function ensureConfigDir() {
190
+ if (!fs.existsSync(CONFIG_DIR)) {
191
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
192
+ }
193
+ }
194
+
195
+ function normalizeConfig(raw) {
196
+ const next = {
197
+ font: DEFAULT_FONT,
198
+ centerDisplay: DEFAULT_CONFIG.centerDisplay,
199
+ showHeader: DEFAULT_CONFIG.showHeader,
200
+ showControls: DEFAULT_CONFIG.showControls,
201
+ tickRateMs: DEFAULT_CONFIG.tickRateMs,
202
+ completionMessage: DEFAULT_CONFIG.completionMessage,
203
+ keybindings: { ...DEFAULT_KEYBINDINGS }
204
+ };
205
+
206
+ if (raw && typeof raw === "object") {
207
+ if (typeof raw.centerDisplay === "boolean") {
208
+ next.centerDisplay = raw.centerDisplay;
209
+ }
210
+ if (typeof raw.showHeader === "boolean") {
211
+ next.showHeader = raw.showHeader;
212
+ }
213
+ if (typeof raw.showControls === "boolean") {
214
+ next.showControls = raw.showControls;
215
+ }
216
+ if (typeof raw.tickRateMs === "number") {
217
+ next.tickRateMs = sanitizeTickRate(raw.tickRateMs);
218
+ }
219
+ if (typeof raw.completionMessage === "string") {
220
+ next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
221
+ }
222
+ next.keybindings = normalizeKeybindings(raw.keybindings);
223
+ if (typeof raw.font === "string") {
224
+ const normalizedFont = normalizeFontName(raw.font);
225
+ if (normalizedFont && isTimerCompatibleFont(normalizedFont)) {
226
+ next.font = normalizedFont;
227
+ }
228
+ }
229
+ }
230
+
231
+ return next;
232
+ }
233
+
234
+ function readConfig() {
235
+ try {
236
+ if (!fs.existsSync(CONFIG_PATH)) {
237
+ return normalizeConfig({});
238
+ }
239
+ const text = fs.readFileSync(CONFIG_PATH, "utf8");
240
+ const parsed = JSON.parse(text);
241
+ return normalizeConfig(parsed);
242
+ } catch (_error) {
243
+ return normalizeConfig({});
244
+ }
245
+ }
246
+
247
+ function writeConfig(config) {
248
+ ensureConfigDir();
249
+ const normalized = normalizeConfig(config);
250
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
251
+ }
252
+
253
+ function updateConfig(patch) {
254
+ const current = readConfig();
255
+ const merged = { ...current, ...patch };
256
+ writeConfig(merged);
257
+ return readConfig();
258
+ }
259
+
260
+ function getFontFromConfig() {
261
+ return readConfig().font || DEFAULT_FONT;
262
+ }
263
+
264
+ function setFontInConfig(requestedFont) {
265
+ const normalized = normalizeFontName(requestedFont);
266
+ if (!normalized) {
267
+ return { ok: false, reason: "unknown", font: null };
268
+ }
269
+ if (!isTimerCompatibleFont(normalized)) {
270
+ return { ok: false, reason: "incompatible", font: null };
271
+ }
272
+ const updated = updateConfig({ font: normalized });
273
+ return { ok: true, reason: null, font: updated.font };
274
+ }
275
+
276
+ function parseDurationArgs(args) {
277
+ if (args.length === 0 || args.length % 2 !== 0) {
278
+ return { ok: false, error: "Duration must be in <number> <unit> pairs." };
279
+ }
280
+
281
+ const unitToSeconds = {
282
+ h: 3600,
283
+ hr: 3600,
284
+ hrs: 3600,
285
+ hour: 3600,
286
+ hours: 3600,
287
+ m: 60,
288
+ min: 60,
289
+ mins: 60,
290
+ minute: 60,
291
+ minutes: 60,
292
+ s: 1,
293
+ sec: 1,
294
+ secs: 1,
295
+ second: 1,
296
+ seconds: 1
297
+ };
298
+
299
+ let totalSeconds = 0;
300
+
301
+ for (let index = 0; index < args.length; index += 2) {
302
+ const numberText = args[index];
303
+ const unitText = args[index + 1];
304
+ const value = Number(numberText);
305
+
306
+ if (!Number.isFinite(value) || value < 0) {
307
+ return { ok: false, error: `Invalid duration number: ${numberText}` };
308
+ }
309
+
310
+ if (!Number.isInteger(value)) {
311
+ return { ok: false, error: `Duration number must be an integer: ${numberText}` };
312
+ }
313
+
314
+ const multiplier = unitToSeconds[unitText.toLowerCase()];
315
+ if (!multiplier) {
316
+ return { ok: false, error: `Unknown unit: ${unitText}` };
317
+ }
318
+
319
+ totalSeconds += value * multiplier;
320
+ }
321
+
322
+ if (totalSeconds <= 0) {
323
+ return { ok: false, error: "Total duration must be greater than zero." };
324
+ }
325
+
326
+ return { ok: true, totalSeconds };
327
+ }
328
+
329
+ function renderTimeAscii(timeText, fontName) {
330
+ const preferred = renderWithFont(timeText, fontName);
331
+ if (hasVisibleGlyphs(preferred)) {
332
+ return preferred;
333
+ }
334
+
335
+ const fallback = renderWithFont(timeText, DEFAULT_FONT);
336
+ if (hasVisibleGlyphs(fallback)) {
337
+ return fallback;
338
+ }
339
+
340
+ return `${timeText}\n`;
341
+ }
342
+
343
+ function keyTokenToLabel(token) {
344
+ if (token === "space") {
345
+ return "Spacebar";
346
+ }
347
+ return token;
348
+ }
349
+
350
+ function controlsHelpLine(keybindings) {
351
+ const pause = `${keyTokenToLabel(keybindings.pauseKey)}/${keyTokenToLabel(keybindings.pauseAltKey)}`;
352
+ const restart = keyTokenToLabel(keybindings.restartKey);
353
+ const exit = `${keyTokenToLabel(keybindings.exitKey)}/${keyTokenToLabel(keybindings.exitAltKey)}/Ctrl+C`;
354
+ return `Controls: ${pause} Pause-Resume | ${restart} Restart | ${exit} Exit`;
355
+ }
356
+
357
+ function keyTokenFromInput(chunk) {
358
+ if (chunk === " ") {
359
+ return "space";
360
+ }
361
+ if (chunk.length !== 1) {
362
+ return null;
363
+ }
364
+ return chunk.toLowerCase();
365
+ }
366
+
367
+ function toDisplayLines(text) {
368
+ const lines = String(text).replace(/\r/g, "").split("\n");
369
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
370
+ lines.pop();
371
+ }
372
+ return lines.length > 0 ? lines : [""];
373
+ }
374
+
375
+ function writeCenteredBlock(lines) {
376
+ const safeLines = lines.length > 0 ? lines : [""];
377
+ const terminalWidth = process.stdout.columns || 120;
378
+ const terminalHeight = process.stdout.rows || safeLines.length;
379
+ const blockWidth = safeLines.reduce((max, line) => Math.max(max, line.length), 0);
380
+
381
+ const padLeft = Math.max(0, Math.floor((terminalWidth - blockWidth) / 2));
382
+ const padTop = Math.max(0, Math.floor((terminalHeight - safeLines.length) / 2));
383
+ const leftPrefix = " ".repeat(padLeft);
384
+
385
+ let output = "";
386
+ if (padTop > 0) {
387
+ output += "\n".repeat(padTop);
388
+ }
389
+ output += safeLines.map((line) => `${leftPrefix}${line}`).join("\n");
390
+ output += "\n";
391
+ process.stdout.write(output);
392
+ }
393
+
394
+ function writeCenteredBlockWithTop(topLines, centerLines) {
395
+ const safeTop = topLines.length > 0 ? topLines : [];
396
+ const safeCenter = centerLines.length > 0 ? centerLines : [""];
397
+ const terminalWidth = process.stdout.columns || 120;
398
+ const terminalHeight = process.stdout.rows || (safeTop.length + safeCenter.length);
399
+ const blockWidth = safeCenter.reduce((max, line) => Math.max(max, line.length), 0);
400
+
401
+ const padLeft = Math.max(0, Math.floor((terminalWidth - blockWidth) / 2));
402
+ const availableHeight = Math.max(0, terminalHeight - safeTop.length);
403
+ const padTop = Math.max(0, Math.floor((availableHeight - safeCenter.length) / 2));
404
+ const leftPrefix = " ".repeat(padLeft);
405
+
406
+ let output = "";
407
+ if (safeTop.length > 0) {
408
+ output += `${safeTop.join("\n")}\n`;
409
+ }
410
+ if (padTop > 0) {
411
+ output += "\n".repeat(padTop);
412
+ }
413
+ output += safeCenter.map((line) => `${leftPrefix}${line}`).join("\n");
414
+ output += "\n";
415
+ process.stdout.write(output);
416
+ }
417
+
418
+ function drawFrame({ mode, seconds, paused, config, done }) {
419
+ clearScreen();
420
+
421
+ const topLines = [];
422
+ const centerLines = [];
423
+ const title = mode === "timer" ? "Timer" : "Stopwatch";
424
+
425
+ if (config.showHeader) {
426
+ topLines.push(`${title} | Font: ${config.font}`);
427
+ }
428
+ if (config.showControls) {
429
+ topLines.push(controlsHelpLine(config.keybindings));
430
+ }
431
+ if (topLines.length > 0) {
432
+ topLines.push("");
433
+ }
434
+
435
+ centerLines.push(...toDisplayLines(renderTimeAscii(formatHms(seconds), config.font)));
436
+
437
+ if (done) {
438
+ if (config.completionMessage) {
439
+ centerLines.push("");
440
+ centerLines.push(config.completionMessage);
441
+ }
442
+ } else if (paused) {
443
+ centerLines.push("");
444
+ centerLines.push("Paused");
445
+ }
446
+
447
+ if (config.centerDisplay) {
448
+ writeCenteredBlockWithTop(topLines, centerLines);
449
+ } else {
450
+ const lines = [...topLines, ...centerLines];
451
+ process.stdout.write(`${lines.join("\n")}\n`);
452
+ }
453
+ }
454
+
455
+ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
456
+ const startedAt = Date.now();
457
+ let lastSecond = null;
458
+
459
+ function printRemaining() {
460
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
461
+ const remaining = Math.max(0, initialSeconds - elapsed);
462
+ if (remaining === lastSecond) {
463
+ return remaining;
464
+ }
465
+ lastSecond = remaining;
466
+ process.stdout.write(`${formatHms(remaining)}\n`);
467
+ return remaining;
468
+ }
469
+
470
+ if (printRemaining() <= 0) {
471
+ return;
472
+ }
473
+
474
+ const interval = setInterval(() => {
475
+ const remaining = printRemaining();
476
+ if (remaining <= 0) {
477
+ clearInterval(interval);
478
+ }
479
+ }, tickRateMs);
480
+ }
481
+
482
+ function runClock({ mode, initialSeconds, config }) {
483
+ const isTimer = mode === "timer";
484
+ const tickRateMs = sanitizeTickRate(config.tickRateMs);
485
+
486
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
487
+ if (!isTimer) {
488
+ process.stderr.write("Stopwatch requires an interactive terminal (TTY).\n");
489
+ process.exitCode = 1;
490
+ return;
491
+ }
492
+ runNonInteractiveTimer(initialSeconds, tickRateMs);
493
+ return;
494
+ }
495
+
496
+ let paused = false;
497
+ let done = false;
498
+ const baseSeconds = initialSeconds;
499
+ let anchorMs = Date.now();
500
+ let elapsedWhilePaused = 0;
501
+ let tick = null;
502
+ let lastDrawState = "";
503
+ let hasExited = false;
504
+
505
+ const stdin = process.stdin;
506
+
507
+ function getElapsedSeconds() {
508
+ const elapsedMs = paused ? elapsedWhilePaused : elapsedWhilePaused + (Date.now() - anchorMs);
509
+ return Math.floor(elapsedMs / 1000);
510
+ }
511
+
512
+ function getDisplaySeconds() {
513
+ const elapsed = getElapsedSeconds();
514
+ if (isTimer) {
515
+ return Math.max(0, baseSeconds - elapsed);
516
+ }
517
+ return elapsed;
518
+ }
519
+
520
+ function refreshDoneState(displaySeconds) {
521
+ if (isTimer && displaySeconds <= 0) {
522
+ done = true;
523
+ paused = true;
524
+ }
525
+ }
526
+
527
+ function draw(force) {
528
+ const displaySeconds = getDisplaySeconds();
529
+ refreshDoneState(displaySeconds);
530
+ const stateKey = `${displaySeconds}|${paused ? 1 : 0}|${done ? 1 : 0}|${process.stdout.columns || 0}|${process.stdout.rows || 0}`;
531
+ if (!force && stateKey === lastDrawState) {
532
+ return;
533
+ }
534
+ lastDrawState = stateKey;
535
+ drawFrame({
536
+ mode,
537
+ seconds: displaySeconds,
538
+ paused,
539
+ config,
540
+ done
541
+ });
542
+ }
543
+
544
+ function restart() {
545
+ paused = false;
546
+ done = false;
547
+ elapsedWhilePaused = 0;
548
+ anchorMs = Date.now();
549
+ lastDrawState = "";
550
+ draw(true);
551
+ }
552
+
553
+ function onSignal() {
554
+ cleanupAndExit(0);
555
+ }
556
+
557
+ function onResize() {
558
+ lastDrawState = "";
559
+ draw(true);
560
+ }
561
+
562
+ function cleanupAndExit(code) {
563
+ if (hasExited) {
564
+ return;
565
+ }
566
+ hasExited = true;
567
+ if (tick !== null) {
568
+ clearInterval(tick);
569
+ }
570
+ stdin.removeListener("data", onKeypress);
571
+ process.removeListener("SIGINT", onSignal);
572
+ process.removeListener("SIGTERM", onSignal);
573
+ process.removeListener("SIGWINCH", onResize);
574
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
575
+ stdin.setRawMode(false);
576
+ }
577
+ stdin.pause();
578
+ showCursor();
579
+ clearScreen();
580
+ process.exit(code);
581
+ }
582
+
583
+ function togglePause() {
584
+ if (paused) {
585
+ paused = false;
586
+ anchorMs = Date.now();
587
+ return;
588
+ }
589
+ paused = true;
590
+ elapsedWhilePaused += Date.now() - anchorMs;
591
+ }
592
+
593
+ function onKeypress(chunk) {
594
+ const key = String(chunk);
595
+ if (key === "\u0003") {
596
+ cleanupAndExit(0);
597
+ return;
598
+ }
599
+
600
+ const token = keyTokenFromInput(key);
601
+ if (!token) {
602
+ return;
603
+ }
604
+
605
+ if (token === config.keybindings.pauseKey || token === config.keybindings.pauseAltKey) {
606
+ if (!done) {
607
+ togglePause();
608
+ draw(true);
609
+ }
610
+ return;
611
+ }
612
+
613
+ if (token === config.keybindings.restartKey) {
614
+ restart();
615
+ return;
616
+ }
617
+
618
+ if (token === config.keybindings.exitKey || token === config.keybindings.exitAltKey) {
619
+ cleanupAndExit(0);
620
+ }
621
+ }
622
+
623
+ process.on("SIGINT", onSignal);
624
+ process.on("SIGTERM", onSignal);
625
+ process.on("SIGWINCH", onResize);
626
+
627
+ if (stdin.isTTY && typeof stdin.setRawMode === "function") {
628
+ stdin.setRawMode(true);
629
+ }
630
+ stdin.resume();
631
+ stdin.setEncoding("utf8");
632
+ stdin.on("data", onKeypress);
633
+ hideCursor();
634
+
635
+ draw(true);
636
+ tick = setInterval(() => draw(false), tickRateMs);
637
+ }
638
+
639
+ function runSettingsUI() {
640
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
641
+ process.stderr.write("`timer settings` requires an interactive terminal (TTY).\n");
642
+ process.exitCode = 1;
643
+ return;
644
+ }
645
+
646
+ ensureConfigDir();
647
+
648
+ const goVersion = spawnSync("go", ["version"], { stdio: "ignore" });
649
+ if (goVersion.error || goVersion.status !== 0) {
650
+ process.stderr.write("Go is required for `timer settings` (Bubble Tea UI).\n");
651
+ process.exitCode = 1;
652
+ return;
653
+ }
654
+
655
+ const state = {
656
+ configPath: CONFIG_PATH,
657
+ config: readConfig(),
658
+ fonts: getAllFonts()
659
+ };
660
+
661
+ fs.writeFileSync(SETTINGS_STATE_PATH, JSON.stringify(state), "utf8");
662
+
663
+ const result = spawnSync("go", ["run", ".", "--state", SETTINGS_STATE_PATH], {
664
+ cwd: path.join(PROJECT_ROOT, "settings-ui"),
665
+ stdio: "inherit"
666
+ });
667
+
668
+ try {
669
+ fs.unlinkSync(SETTINGS_STATE_PATH);
670
+ } catch (_error) {
671
+ }
672
+
673
+ if (result.error) {
674
+ process.stderr.write(`Failed to run settings UI: ${result.error.message}\n`);
675
+ process.exitCode = 1;
676
+ return;
677
+ }
678
+
679
+ if (result.status === 2) {
680
+ return;
681
+ }
682
+
683
+ if (typeof result.status === "number" && result.status !== 0) {
684
+ process.exitCode = result.status;
685
+ }
686
+ }
687
+
688
+ function printUsage() {
689
+ process.stdout.write("Usage\n\n");
690
+ process.stdout.write("Stopwatch\n");
691
+ process.stdout.write(" stopwatch\n\n");
692
+ process.stdout.write("Timer\n");
693
+ process.stdout.write(" timer <number> <hr/hrs/min/sec> [<number> <hr/hrs/min/sec> ...]\n");
694
+ process.stdout.write(" Example: timer 5 min 2 sec\n\n");
695
+ process.stdout.write("Settings\n");
696
+ process.stdout.write(" timer settings\n\n");
697
+ process.stdout.write("Controls\n");
698
+ process.stdout.write(" Defaults: p/Space Pause-Resume | r Restart | q/e/Ctrl+C Exit\n");
699
+ process.stdout.write(" Keybindings are customizable in `timer settings`.\n\n");
700
+ process.stdout.write("Font Styles\n");
701
+ process.stdout.write(" timer style\n");
702
+ process.stdout.write(" timer style --compatible\n");
703
+ process.stdout.write(" timer style <font>\n");
704
+ }
705
+
706
+ function runStopwatch() {
707
+ const config = readConfig();
708
+ runClock({ mode: "stopwatch", initialSeconds: 0, config });
709
+ }
710
+
711
+ function runTimer(args) {
712
+ if (args.length === 0) {
713
+ printUsage();
714
+ process.exitCode = 1;
715
+ return;
716
+ }
717
+
718
+ if (args[0] === "settings") {
719
+ runSettingsUI();
720
+ return;
721
+ }
722
+
723
+ if (args[0] === "style") {
724
+ if (args.length === 1 || (args.length === 2 && args[1] === "--all")) {
725
+ const currentFont = getFontFromConfig();
726
+ const fonts = getAllFonts();
727
+ process.stdout.write(`Current font: ${currentFont}\n\n`);
728
+ process.stdout.write("Available fonts:\n");
729
+ for (const font of fonts) {
730
+ process.stdout.write(`${font}\n`);
731
+ }
732
+ process.stdout.write("\nTip: Some fonts do not support timer digits.\n");
733
+ process.stdout.write("Use `timer style <font>` to validate and set safely.\n");
734
+ return;
735
+ }
736
+
737
+ if (args.length === 2 && args[1] === "--compatible") {
738
+ process.stdout.write("Checking font compatibility for timer digits...\n\n");
739
+ const currentFont = getFontFromConfig();
740
+ const fonts = getTimerCompatibleFontsSlow();
741
+ process.stdout.write(`Current font: ${currentFont}\n\n`);
742
+ process.stdout.write("Timer-compatible fonts:\n");
743
+ for (const font of fonts) {
744
+ process.stdout.write(`${font}\n`);
745
+ }
746
+ return;
747
+ }
748
+
749
+ const requestedFont = args.slice(1).join(" ");
750
+ const result = setFontInConfig(requestedFont);
751
+ if (!result.ok) {
752
+ if (result.reason === "incompatible") {
753
+ process.stderr.write(`Font is incompatible with timer digits: ${requestedFont}\n`);
754
+ } else {
755
+ process.stderr.write(`Unknown font: ${requestedFont}\n`);
756
+ }
757
+ process.stderr.write("Run `timer style` to list fonts.\n");
758
+ process.stderr.write("Run `timer style --compatible` to list only compatible fonts.\n");
759
+ process.exitCode = 1;
760
+ return;
761
+ }
762
+ process.stdout.write(`Font set to: ${result.font}\n`);
763
+ return;
764
+ }
765
+
766
+ const parsed = parseDurationArgs(args);
767
+ if (!parsed.ok) {
768
+ process.stderr.write(`${parsed.error}\n\n`);
769
+ printUsage();
770
+ process.exitCode = 1;
771
+ return;
772
+ }
773
+
774
+ const config = readConfig();
775
+ runClock({ mode: "timer", initialSeconds: parsed.totalSeconds, config });
776
+ }
777
+
778
+ module.exports = {
779
+ runTimer,
780
+ runStopwatch,
781
+ runSettingsUI,
782
+ printUsage,
783
+ parseDurationArgs,
784
+ getAllFonts,
785
+ getTimerCompatibleFontsSlow,
786
+ getFontFromConfig,
787
+ readConfig,
788
+ setFontInConfig,
789
+ DEFAULT_FONT,
790
+ DEFAULT_CONFIG
791
+ };