hn-bits 0.6.0 โ†’ 0.7.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/README.md CHANGED
@@ -79,9 +79,9 @@ hn config set ollama.model llama3.2
79
79
 
80
80
  Missing fields fall back to the defaults above; invalid JSON degrades to "AI disabled" with a warning rather than crashing the reader.
81
81
 
82
- ### Subscriptions + Telegram notifications
82
+ ### Subscriptions + notifications
83
83
 
84
- `hn sub` tracks topic queries; `hn watch --once` checks each one, dedups against past matches, and sends new ones to Telegram. No daemon: schedule `hn watch --once` with cron or launchd.
84
+ `hn sub` tracks topic queries; `hn watch --once` checks each one, dedups against past matches, and sends new ones to Telegram and/or macOS desktop notifications. No daemon: schedule `hn watch --once` with cron or launchd.
85
85
 
86
86
  ```bash
87
87
  hn sub add apple "Apple" --min-points 50 # topic name, Algolia query, min score
@@ -103,6 +103,17 @@ hn config set telegram.enabled true
103
103
 
104
104
  All three keys are required, `telegram.enabled` alone is not enough. With nothing enabled, `hn watch --once` exits with code 2.
105
105
 
106
+ **macOS desktop notifications** (optional, works alone or alongside Telegram) use the [alerter](https://github.com/vjeantet/alerter) binary (`brew install vjeantet/tap/alerter`):
107
+
108
+ ```bash
109
+ hn config set desktopNotifications.enabled true
110
+ hn config set desktopNotifications.timeoutSeconds 10 # optional, default 10
111
+ ```
112
+
113
+ Clicking a notification opens the story URL (HN discussion for text posts): clicks act only while the notification is on screen (`timeoutSeconds`); stale entries in Notification Center just dismiss. Desktop delivery is best-effort: if `alerter` is missing, the watcher warns and skips it (exit 0, nothing marked seen when it's the only channel).
114
+
115
+ One-time macOS setup: alerter sends as Terminal.app, and macOS silently drops notifications from senders without permission. Check System Settings > Notifications > Terminal is allowed (banner style): there is no prompt and no error when it isn't.
116
+
106
117
  ```bash
107
118
  hn watch --once # one pass: query subscriptions, notify new matches, exit
108
119
  ```
@@ -0,0 +1,62 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { hnItemUrl } from '../api/firebase.js';
6
+ // Story data reaches the shell only as positional parameters, never spliced
7
+ // into the script, so titles containing quotes or $() cannot inject.
8
+ // $7 is the absolute alerter path: under cron the child PATH lacks it too.
9
+ const WRAPPER_SCRIPT = 'r=$("$7" --title "$1" --subtitle "$2" --message "$3" --actions Open --timeout "$4" --group "$5"); ' +
10
+ 'case "$r" in @TIMEOUT|@CLOSED) ;; *) open "$6" ;; esac';
11
+ // Cron runs with PATH=/usr/bin:/bin, which contains neither the Homebrew
12
+ // prefix nor ~/.local/bin, so PATH alone would never find alerter under cron.
13
+ const FALLBACK_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.local', 'bin')];
14
+ export function findAlerter() {
15
+ const pathDirs = (process.env.PATH ?? '').split(delimiter);
16
+ for (const dir of [...pathDirs, ...FALLBACK_DIRS]) {
17
+ if (!dir)
18
+ continue;
19
+ const candidate = join(dir, 'alerter');
20
+ try {
21
+ accessSync(candidate, constants.X_OK);
22
+ return candidate;
23
+ }
24
+ catch {
25
+ // not here, keep scanning
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ function wrapperArguments({ subscription, story }, timeoutSeconds, alerterPath) {
31
+ return [
32
+ 'sh',
33
+ `๐Ÿ”” ${subscription.name}`,
34
+ `${story.score} pts ยท ${story.descendants} comments`,
35
+ story.title,
36
+ String(timeoutSeconds),
37
+ `hn-${subscription.id}-${story.id}`,
38
+ story.url ?? hnItemUrl(story.id),
39
+ alerterPath,
40
+ ];
41
+ }
42
+ /** Returns null (after one stderr warning) when alerter is not on PATH. */
43
+ export function createDesktopNotifier(config) {
44
+ const alerterPath = findAlerter();
45
+ if (alerterPath == null) {
46
+ console.error('desktop: alerter not found (brew install vjeantet/tap/alerter), skipping');
47
+ return null;
48
+ }
49
+ return {
50
+ name: 'desktop',
51
+ // Fire-and-forget: the detached wrapper waits out the notification;
52
+ // failures log but never reject, so the watcher's markSeen is not blocked.
53
+ send: async (match) => {
54
+ const child = spawn('sh', ['-c', WRAPPER_SCRIPT, ...wrapperArguments(match, config.timeoutSeconds, alerterPath)], {
55
+ detached: true,
56
+ stdio: 'ignore',
57
+ });
58
+ child.on('error', (err) => console.error(`desktop: spawn failed - ${err.message}`));
59
+ child.unref();
60
+ },
61
+ };
62
+ }
package/dist/watch.js CHANGED
@@ -2,6 +2,7 @@ import { searchRecent } from './api/algolia.js';
2
2
  import { isSeen, markSeen } from './db/seen.js';
3
3
  import { listSubscriptions, touchLastRun } from './db/subscriptions.js';
4
4
  import { loadConfig } from './lib/config.js';
5
+ import { createDesktopNotifier } from './notify/desktop.js';
5
6
  import { createTelegramNotifier } from './notify/telegram.js';
6
7
  const SIX_HOURS = 6 * 60 * 60;
7
8
  const TWENTY_FOUR_HOURS = 24 * 60 * 60;
@@ -15,11 +16,21 @@ function windowStart(lastRunAt, now) {
15
16
  }
16
17
  function buildNotifiers() {
17
18
  const config = loadConfig();
19
+ const notifiers = [];
20
+ let desktopSkipped = false;
18
21
  const telegram = config?.telegram;
19
22
  if (telegram?.enabled && telegram.botToken && telegram.chatId) {
20
- return [createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId })];
23
+ notifiers.push(createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId }));
21
24
  }
22
- return [];
25
+ const desktop = config?.desktopNotifications;
26
+ if (desktop?.enabled) {
27
+ const notifier = createDesktopNotifier({ timeoutSeconds: desktop.timeoutSeconds });
28
+ if (notifier)
29
+ notifiers.push(notifier);
30
+ else
31
+ desktopSkipped = true;
32
+ }
33
+ return { notifiers, desktopSkipped };
23
34
  }
24
35
  async function notifyStory(sub, story, notifiers) {
25
36
  try {
@@ -75,9 +86,11 @@ async function processSubscription(sub, notifiers, now, dryRun) {
75
86
  }
76
87
  /** hn watch --once: one-shot pass over all subscriptions. Returns the process exit code. */
77
88
  export async function runWatch(options) {
78
- const notifiers = buildNotifiers();
89
+ const { notifiers, desktopSkipped } = buildNotifiers();
79
90
  if (notifiers.length === 0) {
80
- console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId)');
91
+ if (desktopSkipped)
92
+ return 0;
93
+ console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId) or desktopNotifications.enabled true');
81
94
  return 2;
82
95
  }
83
96
  const subs = listSubscriptions();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {