@timmy6942025/cli-timer 1.0.0 → 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
@@ -96,6 +85,14 @@ Open interactive settings UI:
96
85
  timer settings
97
86
  ```
98
87
 
88
+ `timer settings` ships with prebuilt Bubble Tea binaries for:
89
+
90
+ - Linux x64 / arm64
91
+ - macOS x64 / arm64
92
+ - Windows x64 / arm64
93
+
94
+ If your platform is unsupported, it falls back to running the Go source (`go run`) when Go is installed.
95
+
99
96
  This launches a Bubble Tea based screen where you can change:
100
97
 
101
98
  - Font
@@ -104,6 +101,7 @@ This launches a Bubble Tea based screen where you can change:
104
101
  - Show controls
105
102
  - Tick rate (50-1000 ms)
106
103
  - Completion message
104
+ - System notification on completion (default On)
107
105
  - Pause key / pause alt key
108
106
  - Restart key
109
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.0",
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
@@ -5,6 +5,7 @@ const { spawnSync } = require("child_process");
5
5
  const figlet = require("figlet");
6
6
 
7
7
  const PROJECT_ROOT = path.join(__dirname, "..");
8
+ const PREBUILT_SETTINGS_UI_DIR = path.join(PROJECT_ROOT, "settings-ui", "prebuilt");
8
9
  const CONFIG_DIR = path.join(os.homedir(), ".cli-timer");
9
10
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
10
11
  const SETTINGS_STATE_PATH = path.join(CONFIG_DIR, "settings-state.json");
@@ -37,6 +38,7 @@ const DEFAULT_CONFIG = Object.freeze({
37
38
  showControls: true,
38
39
  tickRateMs: 100,
39
40
  completionMessage: "Time is up!",
41
+ notifyOnComplete: true,
40
42
  keybindings: { ...DEFAULT_KEYBINDINGS }
41
43
  });
42
44
 
@@ -200,6 +202,7 @@ function normalizeConfig(raw) {
200
202
  showControls: DEFAULT_CONFIG.showControls,
201
203
  tickRateMs: DEFAULT_CONFIG.tickRateMs,
202
204
  completionMessage: DEFAULT_CONFIG.completionMessage,
205
+ notifyOnComplete: DEFAULT_CONFIG.notifyOnComplete,
203
206
  keybindings: { ...DEFAULT_KEYBINDINGS }
204
207
  };
205
208
 
@@ -219,6 +222,9 @@ function normalizeConfig(raw) {
219
222
  if (typeof raw.completionMessage === "string") {
220
223
  next.completionMessage = normalizeCompletionMessage(raw.completionMessage);
221
224
  }
225
+ if (typeof raw.notifyOnComplete === "boolean") {
226
+ next.notifyOnComplete = raw.notifyOnComplete;
227
+ }
222
228
  next.keybindings = normalizeKeybindings(raw.keybindings);
223
229
  if (typeof raw.font === "string") {
224
230
  const normalizedFont = normalizeFontName(raw.font);
@@ -452,9 +458,135 @@ function drawFrame({ mode, seconds, paused, config, done }) {
452
458
  }
453
459
  }
454
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
+
455
586
  function runNonInteractiveTimer(initialSeconds, tickRateMs) {
456
587
  const startedAt = Date.now();
457
588
  let lastSecond = null;
589
+ let notified = false;
458
590
 
459
591
  function printRemaining() {
460
592
  const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -475,6 +607,10 @@ function runNonInteractiveTimer(initialSeconds, tickRateMs) {
475
607
  const remaining = printRemaining();
476
608
  if (remaining <= 0) {
477
609
  clearInterval(interval);
610
+ if (!notified) {
611
+ notified = true;
612
+ notifyTimerFinished(readConfig(), initialSeconds);
613
+ }
478
614
  }
479
615
  }, tickRateMs);
480
616
  }
@@ -495,6 +631,7 @@ function runClock({ mode, initialSeconds, config }) {
495
631
 
496
632
  let paused = false;
497
633
  let done = false;
634
+ let didNotifyCompletion = false;
498
635
  const baseSeconds = initialSeconds;
499
636
  let anchorMs = Date.now();
500
637
  let elapsedWhilePaused = 0;
@@ -518,9 +655,13 @@ function runClock({ mode, initialSeconds, config }) {
518
655
  }
519
656
 
520
657
  function refreshDoneState(displaySeconds) {
521
- if (isTimer && displaySeconds <= 0) {
658
+ if (isTimer && displaySeconds <= 0 && !done) {
522
659
  done = true;
523
660
  paused = true;
661
+ if (!didNotifyCompletion) {
662
+ didNotifyCompletion = true;
663
+ notifyTimerFinished(config, baseSeconds);
664
+ }
524
665
  }
525
666
  }
526
667
 
@@ -544,6 +685,7 @@ function runClock({ mode, initialSeconds, config }) {
544
685
  function restart() {
545
686
  paused = false;
546
687
  done = false;
688
+ didNotifyCompletion = false;
547
689
  elapsedWhilePaused = 0;
548
690
  anchorMs = Date.now();
549
691
  lastDrawState = "";
@@ -645,11 +787,52 @@ function runSettingsUI() {
645
787
 
646
788
  ensureConfigDir();
647
789
 
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;
790
+ const platformMap = {
791
+ linux: "linux",
792
+ darwin: "darwin",
793
+ win32: "windows"
794
+ };
795
+ const archMap = {
796
+ x64: "x64",
797
+ arm64: "arm64"
798
+ };
799
+
800
+ function getSettingsBinaryTarget() {
801
+ const platform = platformMap[process.platform];
802
+ const arch = archMap[process.arch];
803
+ if (!platform || !arch) {
804
+ return null;
805
+ }
806
+ return `${platform}-${arch}`;
807
+ }
808
+
809
+ function getPrebuiltSettingsBinaryPath() {
810
+ const target = getSettingsBinaryTarget();
811
+ if (!target) {
812
+ return null;
813
+ }
814
+ const binaryName = process.platform === "win32" ? "cli-timer-settings-ui.exe" : "cli-timer-settings-ui";
815
+ const fullPath = path.join(PREBUILT_SETTINGS_UI_DIR, target, binaryName);
816
+ if (!fs.existsSync(fullPath)) {
817
+ return null;
818
+ }
819
+ return fullPath;
820
+ }
821
+
822
+ function hasGoToolchain() {
823
+ const goVersion = spawnSync("go", ["version"], { stdio: "ignore" });
824
+ return !(goVersion.error || goVersion.status !== 0);
825
+ }
826
+
827
+ function runPrebuiltSettingsUI(binaryPath) {
828
+ return spawnSync(binaryPath, ["--state", SETTINGS_STATE_PATH], { stdio: "inherit" });
829
+ }
830
+
831
+ function runGoSettingsUI() {
832
+ return spawnSync("go", ["run", ".", "--state", SETTINGS_STATE_PATH], {
833
+ cwd: path.join(PROJECT_ROOT, "settings-ui"),
834
+ stdio: "inherit"
835
+ });
653
836
  }
654
837
 
655
838
  const state = {
@@ -660,16 +843,39 @@ function runSettingsUI() {
660
843
 
661
844
  fs.writeFileSync(SETTINGS_STATE_PATH, JSON.stringify(state), "utf8");
662
845
 
663
- const result = spawnSync("go", ["run", ".", "--state", SETTINGS_STATE_PATH], {
664
- cwd: path.join(PROJECT_ROOT, "settings-ui"),
665
- stdio: "inherit"
666
- });
846
+ let result;
847
+ const prebuiltPath = getPrebuiltSettingsBinaryPath();
848
+ const canRunGo = hasGoToolchain();
849
+
850
+ if (prebuiltPath) {
851
+ result = runPrebuiltSettingsUI(prebuiltPath);
852
+ if (result.error && canRunGo) {
853
+ result = runGoSettingsUI();
854
+ }
855
+ } else if (canRunGo) {
856
+ result = runGoSettingsUI();
857
+ } else {
858
+ const target = getSettingsBinaryTarget();
859
+ if (target) {
860
+ process.stderr.write(`No prebuilt settings UI binary for ${target}, and Go is not installed.\n`);
861
+ } else {
862
+ process.stderr.write(
863
+ `No prebuilt settings UI binary for ${process.platform}/${process.arch}, and Go is not installed.\n`
864
+ );
865
+ }
866
+ process.stderr.write("Install Go or use a supported platform for `timer settings`.\n");
867
+ process.exitCode = 1;
868
+ }
667
869
 
668
870
  try {
669
871
  fs.unlinkSync(SETTINGS_STATE_PATH);
670
872
  } catch (_error) {
671
873
  }
672
874
 
875
+ if (!result) {
876
+ return;
877
+ }
878
+
673
879
  if (result.error) {
674
880
  process.stderr.write(`Failed to run settings UI: ${result.error.message}\n`);
675
881
  process.exitCode = 1;