@timmy6942025/cli-timer 1.0.1 → 1.1.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.
@@ -0,0 +1,34 @@
1
+ name: npm-publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Setup Node.js
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 22
23
+ registry-url: https://registry.npmjs.org
24
+
25
+ - name: Install dependencies
26
+ run: npm ci
27
+
28
+ - name: Run tests
29
+ run: npm test
30
+
31
+ - name: Publish package
32
+ run: npm publish --access public --provenance
33
+ env:
34
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -2,28 +2,17 @@
2
2
 
3
3
  A simple and customizable, easy to setup/use timer and stopwatch that runs in your terminal.
4
4
 
5
- ## Setup
5
+ ## Install
6
6
 
7
- Install dependencies:
7
+ Install globally (this is all you need):
8
8
 
9
9
  ```bash
10
- npm install
10
+ npm i -g @timmy6942025/cli-timer
11
+ # or
12
+ bun add -g @timmy6942025/cli-timer
11
13
  ```
12
14
 
13
- Run directly from project root:
14
-
15
- ```bash
16
- node bin/timer.js 5 min
17
- node bin/stopwatch.js
18
- ```
19
-
20
- Or install globally from this folder:
21
-
22
- ```bash
23
- npm link
24
- ```
25
-
26
- Then use commands:
15
+ Then use commands anywhere:
27
16
 
28
17
  ```bash
29
18
  timer 5 min
@@ -112,6 +101,7 @@ This launches a Bubble Tea based screen where you can change:
112
101
  - Show controls
113
102
  - Tick rate (50-1000 ms)
114
103
  - Completion message
104
+ - System notification on completion (default On)
115
105
  - Pause key / pause alt key
116
106
  - Restart key
117
107
  - Exit key / exit alt key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmy6942025/cli-timer",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Simple customizable terminal timer and stopwatch",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -45,6 +45,7 @@ type config struct {
45
45
  ShowControls bool `json:"showControls"`
46
46
  TickRateMs int `json:"tickRateMs"`
47
47
  CompletionMessage string `json:"completionMessage"`
48
+ NotifyOnComplete bool `json:"notifyOnComplete"`
48
49
  Keybindings keybindings `json:"keybindings"`
49
50
  }
50
51
 
@@ -149,6 +150,7 @@ func buildMenuItems(cfg config) []list.Item {
149
150
  menuEntry{id: "controls", title: "Show controls", description: boolText(cfg.ShowControls)},
150
151
  menuEntry{id: "tickRate", title: "Tick rate", description: fmt.Sprintf("%d ms", cfg.TickRateMs)},
151
152
  menuEntry{id: "message", title: "Completion message", description: summarizeMessage(cfg.CompletionMessage)},
153
+ menuEntry{id: "notify", title: "System notification", description: boolText(cfg.NotifyOnComplete)},
152
154
  menuEntry{id: "pauseKey", title: "Pause key", description: keyTokenLabel(cfg.Keybindings.PauseKey)},
153
155
  menuEntry{id: "pauseAltKey", title: "Pause alt key", description: keyTokenLabel(cfg.Keybindings.PauseAltKey)},
154
156
  menuEntry{id: "restartKey", title: "Restart key", description: keyTokenLabel(cfg.Keybindings.RestartKey)},
@@ -230,6 +232,7 @@ func normalizeConfig(cfg config) config {
230
232
  ShowControls: true,
231
233
  TickRateMs: defaultTickRateMs,
232
234
  CompletionMessage: defaultCompletionMessage,
235
+ NotifyOnComplete: true,
233
236
  Keybindings: defaultKeybindings,
234
237
  }
235
238
 
@@ -245,6 +248,7 @@ func normalizeConfig(cfg config) config {
245
248
  if cfg.CompletionMessage != "" {
246
249
  result.CompletionMessage = normalizeCompletionMessage(cfg.CompletionMessage)
247
250
  }
251
+ result.NotifyOnComplete = cfg.NotifyOnComplete
248
252
  result.Keybindings = normalizeKeybindings(cfg.Keybindings)
249
253
  return result
250
254
  }
@@ -454,6 +458,10 @@ func (m *model) applyMenuAction() tea.Cmd {
454
458
  m.messageInput.Focus()
455
459
  m.screen = screenMessageEditor
456
460
  return nil
461
+ case "notify":
462
+ m.payload.Config.NotifyOnComplete = !m.payload.Config.NotifyOnComplete
463
+ m.refreshMenu()
464
+ return nil
457
465
  case "pauseKey":
458
466
  m.openKeyPicker("pauseKey", "Select Pause Key")
459
467
  return nil
@@ -16,6 +16,7 @@ func testPayload() statePayload {
16
16
  ShowControls: true,
17
17
  TickRateMs: 100,
18
18
  CompletionMessage: "Time is up!",
19
+ NotifyOnComplete: false,
19
20
  Keybindings: defaultKeybindings,
20
21
  },
21
22
  Fonts: []string{"Standard", "Big"},
package/src/index.js CHANGED
@@ -38,6 +38,7 @@ const DEFAULT_CONFIG = Object.freeze({
38
38
  showControls: true,
39
39
  tickRateMs: 100,
40
40
  completionMessage: "Time is up!",
41
+ notifyOnComplete: true,
41
42
  keybindings: { ...DEFAULT_KEYBINDINGS }
42
43
  });
43
44
 
@@ -201,6 +202,7 @@ function normalizeConfig(raw) {
201
202
  showControls: DEFAULT_CONFIG.showControls,
202
203
  tickRateMs: DEFAULT_CONFIG.tickRateMs,
203
204
  completionMessage: DEFAULT_CONFIG.completionMessage,
205
+ notifyOnComplete: DEFAULT_CONFIG.notifyOnComplete,
204
206
  keybindings: { ...DEFAULT_KEYBINDINGS }
205
207
  };
206
208
 
@@ -220,6 +222,9 @@ function normalizeConfig(raw) {
220
222
  if (typeof raw.completionMessage === "string") {
221
223
  next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
222
224
  }
225
+ if (typeof raw.notifyOnComplete === "boolean") {
226
+ next.notifyOnComplete = raw.notifyOnComplete;
227
+ }
223
228
  next.keybindings = normalizeKeybindings(raw.keybindings);
224
229
  if (typeof raw.font === "string") {
225
230
  const normalizedFont = normalizeFontName(raw.font);
@@ -453,9 +458,135 @@ function drawFrame({ mode, seconds, paused, config, done }) {
453
458
  }
454
459
  }
455
460
 
461
+ function escapeAppleScriptString(value) {
462
+ return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
463
+ }
464
+
465
+ function escapePowerShellSingleQuotedString(value) {
466
+ return String(value).replace(/'/g, "''");
467
+ }
468
+
469
+ function spawnOk(command, args, options) {
470
+ try {
471
+ const result = spawnSync(command, args, { stdio: "ignore", ...options });
472
+ return !result.error && result.status === 0;
473
+ } catch (_error) {
474
+ return false;
475
+ }
476
+ }
477
+
478
+ function sendSystemNotification({ title, message }) {
479
+ const safeTitle = String(title || "").trim() || "Timer";
480
+ const safeMessage = String(message || "").trim();
481
+
482
+ if (!safeMessage) {
483
+ return false;
484
+ }
485
+
486
+ try {
487
+ if (process.platform === "darwin") {
488
+ const script = `display notification "${escapeAppleScriptString(safeMessage)}" with title "${escapeAppleScriptString(safeTitle)}"`;
489
+ if (spawnOk("osascript", ["-e", script])) {
490
+ return true;
491
+ }
492
+ if (spawnOk("terminal-notifier", ["-title", safeTitle, "-message", safeMessage])) {
493
+ return true;
494
+ }
495
+ return false;
496
+ }
497
+
498
+ if (process.platform === "win32") {
499
+ const titlePs = escapePowerShellSingleQuotedString(safeTitle);
500
+ const messagePs = escapePowerShellSingleQuotedString(safeMessage);
501
+
502
+ const ps = [
503
+ "$ErrorActionPreference = 'Stop'",
504
+ "try {",
505
+ " [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null",
506
+ " $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02",
507
+ " $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)",
508
+ " $txt = $xml.GetElementsByTagName('text')",
509
+ ` $txt.Item(0).AppendChild($xml.CreateTextNode('${titlePs}')) > $null`,
510
+ ` $txt.Item(1).AppendChild($xml.CreateTextNode('${messagePs}')) > $null`,
511
+ " $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
512
+ " $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cli-timer')",
513
+ " $notifier.Show($toast)",
514
+ "} catch { exit 1 }"
515
+ ].join("; ");
516
+
517
+ const powershellArgs = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Sta", "-Command", ps];
518
+ if (spawnOk("powershell", powershellArgs, { windowsHide: true })) {
519
+ return true;
520
+ }
521
+
522
+ const balloon = [
523
+ "$ErrorActionPreference = 'Stop'",
524
+ "try {",
525
+ " Add-Type -AssemblyName System.Windows.Forms",
526
+ " Add-Type -AssemblyName System.Drawing",
527
+ " $notify = New-Object System.Windows.Forms.NotifyIcon",
528
+ " $notify.Icon = [System.Drawing.SystemIcons]::Information",
529
+ ` $notify.BalloonTipTitle = '${titlePs}'`,
530
+ ` $notify.BalloonTipText = '${messagePs}'`,
531
+ " $notify.Visible = $true",
532
+ " $notify.ShowBalloonTip(5000)",
533
+ " Start-Sleep -Milliseconds 5500",
534
+ " $notify.Dispose()",
535
+ "} catch { exit 1 }"
536
+ ].join("; ");
537
+
538
+ const balloonArgs = [
539
+ "-NoProfile",
540
+ "-NonInteractive",
541
+ "-ExecutionPolicy",
542
+ "Bypass",
543
+ "-Sta",
544
+ "-Command",
545
+ balloon
546
+ ];
547
+ return spawnOk("powershell", balloonArgs, { windowsHide: true });
548
+ }
549
+
550
+ if (process.platform === "linux") {
551
+ if (spawnOk("termux-notification", ["--title", safeTitle, "--content", safeMessage])) {
552
+ return true;
553
+ }
554
+ if (spawnOk("notify-send", [safeTitle, safeMessage])) {
555
+ return true;
556
+ }
557
+ if (spawnOk("kdialog", ["--title", safeTitle, "--passivepopup", safeMessage, "5"])) {
558
+ return true;
559
+ }
560
+ if (spawnOk("zenity", ["--notification", `--text=${safeTitle}: ${safeMessage}`])) {
561
+ return true;
562
+ }
563
+ return false;
564
+ }
565
+ } catch (_error) {
566
+ }
567
+
568
+ return false;
569
+ }
570
+
571
+ function notifyTimerFinished(config, initialSeconds) {
572
+ if (!config || !config.notifyOnComplete) {
573
+ return;
574
+ }
575
+ const message = config.completionMessage || "Time is up!";
576
+ const title = initialSeconds ? `Timer finished (${formatHms(initialSeconds)})` : "Timer finished";
577
+ const notified = sendSystemNotification({ title, message });
578
+ if (!notified) {
579
+ try {
580
+ process.stderr.write("\x07");
581
+ } catch (_error) {
582
+ }
583
+ }
584
+ }
585
+
456
586
  function runNonInteractiveTimer(initialSeconds, tickRateMs) {
457
587
  const startedAt = Date.now();
458
588
  let lastSecond = null;
589
+ let notified = false;
459
590
 
460
591
  function printRemaining() {
461
592
  const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -476,6 +607,10 @@ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
476
607
  const remaining = printRemaining();
477
608
  if (remaining <= 0) {
478
609
  clearInterval(interval);
610
+ if (!notified) {
611
+ notified = true;
612
+ notifyTimerFinished(readConfig(), initialSeconds);
613
+ }
479
614
  }
480
615
  }, tickRateMs);
481
616
  }
@@ -496,6 +631,7 @@ function runClock({ mode, initialSeconds, config }) {
496
631
 
497
632
  let paused = false;
498
633
  let done = false;
634
+ let didNotifyCompletion = false;
499
635
  const baseSeconds = initialSeconds;
500
636
  let anchorMs = Date.now();
501
637
  let elapsedWhilePaused = 0;
@@ -519,9 +655,13 @@ function runClock({ mode, initialSeconds, config }) {
519
655
  }
520
656
 
521
657
  function refreshDoneState(displaySeconds) {
522
- if (isTimer && displaySeconds <= 0) {
658
+ if (isTimer && displaySeconds <= 0 && !done) {
523
659
  done = true;
524
660
  paused = true;
661
+ if (!didNotifyCompletion) {
662
+ didNotifyCompletion = true;
663
+ notifyTimerFinished(config, baseSeconds);
664
+ }
525
665
  }
526
666
  }
527
667
 
@@ -545,6 +685,7 @@ function runClock({ mode, initialSeconds, config }) {
545
685
  function restart() {
546
686
  paused = false;
547
687
  done = false;
688
+ didNotifyCompletion = false;
548
689
  elapsedWhilePaused = 0;
549
690
  anchorMs = Date.now();
550
691
  lastDrawState = "";