@timmy6942025/cli-timer 1.0.1 → 1.1.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.
@@ -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,8 @@ 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)
105
+ - Completion sound/alarm on completion (default Off)
115
106
  - Pause key / pause alt key
116
107
  - Restart key
117
108
  - 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.1",
4
4
  "description": "Simple customizable terminal timer and stopwatch",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -39,13 +39,15 @@ var defaultKeybindings = keybindings{
39
39
  }
40
40
 
41
41
  type config struct {
42
- Font string `json:"font"`
43
- CenterDisplay bool `json:"centerDisplay"`
44
- ShowHeader bool `json:"showHeader"`
45
- ShowControls bool `json:"showControls"`
46
- TickRateMs int `json:"tickRateMs"`
47
- CompletionMessage string `json:"completionMessage"`
48
- Keybindings keybindings `json:"keybindings"`
42
+ Font string `json:"font"`
43
+ CenterDisplay bool `json:"centerDisplay"`
44
+ ShowHeader bool `json:"showHeader"`
45
+ ShowControls bool `json:"showControls"`
46
+ TickRateMs int `json:"tickRateMs"`
47
+ CompletionMessage string `json:"completionMessage"`
48
+ NotifyOnComplete bool `json:"notifyOnComplete"`
49
+ PlaySoundOnComplete bool `json:"playSoundOnComplete"`
50
+ Keybindings keybindings `json:"keybindings"`
49
51
  }
50
52
 
51
53
  type statePayload struct {
@@ -149,6 +151,8 @@ func buildMenuItems(cfg config) []list.Item {
149
151
  menuEntry{id: "controls", title: "Show controls", description: boolText(cfg.ShowControls)},
150
152
  menuEntry{id: "tickRate", title: "Tick rate", description: fmt.Sprintf("%d ms", cfg.TickRateMs)},
151
153
  menuEntry{id: "message", title: "Completion message", description: summarizeMessage(cfg.CompletionMessage)},
154
+ menuEntry{id: "notify", title: "System notification", description: boolText(cfg.NotifyOnComplete)},
155
+ menuEntry{id: "sound", title: "Completion sound/alarm", description: boolText(cfg.PlaySoundOnComplete)},
152
156
  menuEntry{id: "pauseKey", title: "Pause key", description: keyTokenLabel(cfg.Keybindings.PauseKey)},
153
157
  menuEntry{id: "pauseAltKey", title: "Pause alt key", description: keyTokenLabel(cfg.Keybindings.PauseAltKey)},
154
158
  menuEntry{id: "restartKey", title: "Restart key", description: keyTokenLabel(cfg.Keybindings.RestartKey)},
@@ -224,13 +228,15 @@ func normalizeKeybindings(cfg keybindings) keybindings {
224
228
 
225
229
  func normalizeConfig(cfg config) config {
226
230
  result := config{
227
- Font: defaultFont,
228
- CenterDisplay: true,
229
- ShowHeader: true,
230
- ShowControls: true,
231
- TickRateMs: defaultTickRateMs,
232
- CompletionMessage: defaultCompletionMessage,
233
- Keybindings: defaultKeybindings,
231
+ Font: defaultFont,
232
+ CenterDisplay: true,
233
+ ShowHeader: true,
234
+ ShowControls: true,
235
+ TickRateMs: defaultTickRateMs,
236
+ CompletionMessage: defaultCompletionMessage,
237
+ NotifyOnComplete: true,
238
+ PlaySoundOnComplete: false,
239
+ Keybindings: defaultKeybindings,
234
240
  }
235
241
 
236
242
  if strings.TrimSpace(cfg.Font) != "" {
@@ -245,6 +251,8 @@ func normalizeConfig(cfg config) config {
245
251
  if cfg.CompletionMessage != "" {
246
252
  result.CompletionMessage = normalizeCompletionMessage(cfg.CompletionMessage)
247
253
  }
254
+ result.NotifyOnComplete = cfg.NotifyOnComplete
255
+ result.PlaySoundOnComplete = cfg.PlaySoundOnComplete
248
256
  result.Keybindings = normalizeKeybindings(cfg.Keybindings)
249
257
  return result
250
258
  }
@@ -454,6 +462,14 @@ func (m *model) applyMenuAction() tea.Cmd {
454
462
  m.messageInput.Focus()
455
463
  m.screen = screenMessageEditor
456
464
  return nil
465
+ case "notify":
466
+ m.payload.Config.NotifyOnComplete = !m.payload.Config.NotifyOnComplete
467
+ m.refreshMenu()
468
+ return nil
469
+ case "sound":
470
+ m.payload.Config.PlaySoundOnComplete = !m.payload.Config.PlaySoundOnComplete
471
+ m.refreshMenu()
472
+ return nil
457
473
  case "pauseKey":
458
474
  m.openKeyPicker("pauseKey", "Select Pause Key")
459
475
  return nil
@@ -10,13 +10,15 @@ func testPayload() statePayload {
10
10
  return statePayload{
11
11
  ConfigPath: "/tmp/cli-timer-test-config.json",
12
12
  Config: config{
13
- Font: "Standard",
14
- CenterDisplay: true,
15
- ShowHeader: true,
16
- ShowControls: true,
17
- TickRateMs: 100,
18
- CompletionMessage: "Time is up!",
19
- Keybindings: defaultKeybindings,
13
+ Font: "Standard",
14
+ CenterDisplay: true,
15
+ ShowHeader: true,
16
+ ShowControls: true,
17
+ TickRateMs: 100,
18
+ CompletionMessage: "Time is up!",
19
+ NotifyOnComplete: false,
20
+ PlaySoundOnComplete: false,
21
+ Keybindings: defaultKeybindings,
20
22
  },
21
23
  Fonts: []string{"Standard", "Big"},
22
24
  }
package/src/index.js CHANGED
@@ -38,6 +38,8 @@ const DEFAULT_CONFIG = Object.freeze({
38
38
  showControls: true,
39
39
  tickRateMs: 100,
40
40
  completionMessage: "Time is up!",
41
+ notifyOnComplete: true,
42
+ playSoundOnComplete: false,
41
43
  keybindings: { ...DEFAULT_KEYBINDINGS }
42
44
  });
43
45
 
@@ -201,6 +203,8 @@ function normalizeConfig(raw) {
201
203
  showControls: DEFAULT_CONFIG.showControls,
202
204
  tickRateMs: DEFAULT_CONFIG.tickRateMs,
203
205
  completionMessage: DEFAULT_CONFIG.completionMessage,
206
+ notifyOnComplete: DEFAULT_CONFIG.notifyOnComplete,
207
+ playSoundOnComplete: DEFAULT_CONFIG.playSoundOnComplete,
204
208
  keybindings: { ...DEFAULT_KEYBINDINGS }
205
209
  };
206
210
 
@@ -220,6 +224,12 @@ function normalizeConfig(raw) {
220
224
  if (typeof raw.completionMessage === "string") {
221
225
  next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
222
226
  }
227
+ if (typeof raw.notifyOnComplete === "boolean") {
228
+ next.notifyOnComplete = raw.notifyOnComplete;
229
+ }
230
+ if (typeof raw.playSoundOnComplete === "boolean") {
231
+ next.playSoundOnComplete = raw.playSoundOnComplete;
232
+ }
223
233
  next.keybindings = normalizeKeybindings(raw.keybindings);
224
234
  if (typeof raw.font === "string") {
225
235
  const normalizedFont = normalizeFontName(raw.font);
@@ -453,9 +463,144 @@ function drawFrame({ mode, seconds, paused, config, done }) {
453
463
  }
454
464
  }
455
465
 
466
+ function escapeAppleScriptString(value) {
467
+ return String(value).replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
468
+ }
469
+
470
+ function escapePowerShellSingleQuotedString(value) {
471
+ return String(value).replace(/'/g, "''");
472
+ }
473
+
474
+ function spawnOk(command, args, options) {
475
+ try {
476
+ const result = spawnSync(command, args, { stdio: "ignore", ...options });
477
+ return !result.error && result.status === 0;
478
+ } catch (_error) {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ function sendSystemNotification({ title, message }) {
484
+ const safeTitle = String(title || "").trim() || "Timer";
485
+ const safeMessage = String(message || "").trim();
486
+
487
+ if (!safeMessage) {
488
+ return false;
489
+ }
490
+
491
+ try {
492
+ if (process.platform === "darwin") {
493
+ const script = `display notification "${escapeAppleScriptString(safeMessage)}" with title "${escapeAppleScriptString(safeTitle)}"`;
494
+ if (spawnOk("osascript", ["-e", script])) {
495
+ return true;
496
+ }
497
+ if (spawnOk("terminal-notifier", ["-title", safeTitle, "-message", safeMessage])) {
498
+ return true;
499
+ }
500
+ return false;
501
+ }
502
+
503
+ if (process.platform === "win32") {
504
+ const titlePs = escapePowerShellSingleQuotedString(safeTitle);
505
+ const messagePs = escapePowerShellSingleQuotedString(safeMessage);
506
+
507
+ const ps = [
508
+ "$ErrorActionPreference = 'Stop'",
509
+ "try {",
510
+ " [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null",
511
+ " $template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02",
512
+ " $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template)",
513
+ " $txt = $xml.GetElementsByTagName('text')",
514
+ ` $txt.Item(0).AppendChild($xml.CreateTextNode('${titlePs}')) > $null`,
515
+ ` $txt.Item(1).AppendChild($xml.CreateTextNode('${messagePs}')) > $null`,
516
+ " $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
517
+ " $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cli-timer')",
518
+ " $notifier.Show($toast)",
519
+ "} catch { exit 1 }"
520
+ ].join("; ");
521
+
522
+ const powershellArgs = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Sta", "-Command", ps];
523
+ if (spawnOk("powershell", powershellArgs, { windowsHide: true })) {
524
+ return true;
525
+ }
526
+
527
+ const balloon = [
528
+ "$ErrorActionPreference = 'Stop'",
529
+ "try {",
530
+ " Add-Type -AssemblyName System.Windows.Forms",
531
+ " Add-Type -AssemblyName System.Drawing",
532
+ " $notify = New-Object System.Windows.Forms.NotifyIcon",
533
+ " $notify.Icon = [System.Drawing.SystemIcons]::Information",
534
+ ` $notify.BalloonTipTitle = '${titlePs}'`,
535
+ ` $notify.BalloonTipText = '${messagePs}'`,
536
+ " $notify.Visible = $true",
537
+ " $notify.ShowBalloonTip(5000)",
538
+ " Start-Sleep -Milliseconds 5500",
539
+ " $notify.Dispose()",
540
+ "} catch { exit 1 }"
541
+ ].join("; ");
542
+
543
+ const balloonArgs = [
544
+ "-NoProfile",
545
+ "-NonInteractive",
546
+ "-ExecutionPolicy",
547
+ "Bypass",
548
+ "-Sta",
549
+ "-Command",
550
+ balloon
551
+ ];
552
+ return spawnOk("powershell", balloonArgs, { windowsHide: true });
553
+ }
554
+
555
+ if (process.platform === "linux") {
556
+ if (spawnOk("termux-notification", ["--title", safeTitle, "--content", safeMessage])) {
557
+ return true;
558
+ }
559
+ if (spawnOk("notify-send", [safeTitle, safeMessage])) {
560
+ return true;
561
+ }
562
+ if (spawnOk("kdialog", ["--title", safeTitle, "--passivepopup", safeMessage, "5"])) {
563
+ return true;
564
+ }
565
+ if (spawnOk("zenity", ["--notification", `--text=${safeTitle}: ${safeMessage}`])) {
566
+ return true;
567
+ }
568
+ return false;
569
+ }
570
+ } catch (_error) {
571
+ }
572
+
573
+ return false;
574
+ }
575
+
576
+ function playCompletionAlarm(config) {
577
+ if (!config || !config.playSoundOnComplete) {
578
+ return;
579
+ }
580
+ try {
581
+ process.stderr.write("\x07\x07\x07");
582
+ } catch (_error) {
583
+ }
584
+ }
585
+
586
+ function notifyTimerFinished(config, initialSeconds) {
587
+ if (!config) {
588
+ return;
589
+ }
590
+
591
+ if (config.notifyOnComplete) {
592
+ const message = config.completionMessage || "Time is up!";
593
+ const title = initialSeconds ? `Timer finished (${formatHms(initialSeconds)})` : "Timer finished";
594
+ sendSystemNotification({ title, message });
595
+ }
596
+
597
+ playCompletionAlarm(config);
598
+ }
599
+
456
600
  function runNonInteractiveTimer(initialSeconds, tickRateMs) {
457
601
  const startedAt = Date.now();
458
602
  let lastSecond = null;
603
+ let notified = false;
459
604
 
460
605
  function printRemaining() {
461
606
  const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -476,6 +621,10 @@ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
476
621
  const remaining = printRemaining();
477
622
  if (remaining <= 0) {
478
623
  clearInterval(interval);
624
+ if (!notified) {
625
+ notified = true;
626
+ notifyTimerFinished(readConfig(), initialSeconds);
627
+ }
479
628
  }
480
629
  }, tickRateMs);
481
630
  }
@@ -496,6 +645,7 @@ function runClock({ mode, initialSeconds, config }) {
496
645
 
497
646
  let paused = false;
498
647
  let done = false;
648
+ let didNotifyCompletion = false;
499
649
  const baseSeconds = initialSeconds;
500
650
  let anchorMs = Date.now();
501
651
  let elapsedWhilePaused = 0;
@@ -519,9 +669,13 @@ function runClock({ mode, initialSeconds, config }) {
519
669
  }
520
670
 
521
671
  function refreshDoneState(displaySeconds) {
522
- if (isTimer && displaySeconds <= 0) {
672
+ if (isTimer && displaySeconds <= 0 && !done) {
523
673
  done = true;
524
674
  paused = true;
675
+ if (!didNotifyCompletion) {
676
+ didNotifyCompletion = true;
677
+ notifyTimerFinished(config, baseSeconds);
678
+ }
525
679
  }
526
680
  }
527
681
 
@@ -545,6 +699,7 @@ function runClock({ mode, initialSeconds, config }) {
545
699
  function restart() {
546
700
  paused = false;
547
701
  done = false;
702
+ didNotifyCompletion = false;
548
703
  elapsedWhilePaused = 0;
549
704
  anchorMs = Date.now();
550
705
  lastDrawState = "";