hn-bits 0.4.0 → 0.5.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
@@ -107,7 +107,15 @@ All three keys are required, `telegram.enabled` alone is not enough. With nothin
107
107
  hn watch --once # one pass: query subscriptions, notify new matches, exit
108
108
  ```
109
109
 
110
- Schedule it, e.g. every 30 minutes via crontab:
110
+ The first `hn sub add` offers to install a cron job for you (every 30 minutes). Manage it anytime via the CLI, or `c` on the Subs tab (which also shows a live installed/not-installed status line):
111
+
112
+ ```bash
113
+ hn schedule status # installed / not installed
114
+ hn schedule install # install now (no-op if already installed)
115
+ hn schedule remove # uninstall
116
+ ```
117
+
118
+ Or set it up by hand:
111
119
 
112
120
  ```
113
121
  */30 * * * * cd /path/to/hn-bits && hn watch --once
@@ -188,6 +196,9 @@ Same as story list, minus search; `B` removes a bookmark instead of adding one.
188
196
  | `a` | add subscription |
189
197
  | `e` | edit subscription |
190
198
  | `d` | delete subscription |
199
+ | `c` | toggle watch cron schedule (install/remove) |
200
+
201
+ Shows a `schedule: installed (every 30 min)` / `schedule: not installed` status line, so you don't need to drop to `hn schedule status` to check.
191
202
 
192
203
  **Sub matches** (opened from Subs via `enter`)
193
204
 
package/dist/index.js CHANGED
@@ -112,6 +112,24 @@ config
112
112
  }
113
113
  });
114
114
  const sub = program.command('sub').description('Manage topic subscriptions');
115
+ async function promptInstallSchedule() {
116
+ const readline = await import('node:readline/promises');
117
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
118
+ const answer = await rl.question('No watch schedule installed yet. Install one now (every 30 min)? [y/N] ');
119
+ rl.close();
120
+ if (!/^y(es)?$/i.test(answer.trim())) {
121
+ console.log("skipped. Run 'hn schedule install' anytime, or add the cron line yourself (see README).");
122
+ return;
123
+ }
124
+ const { installScheduledJob } = await import('./lib/schedule.js');
125
+ try {
126
+ const line = installScheduledJob();
127
+ console.log(`installed: ${line}`);
128
+ }
129
+ catch {
130
+ console.log("couldn't find 'hn' on PATH — add the cron line yourself (see README) or run 'hn schedule install' later.");
131
+ }
132
+ }
115
133
  sub
116
134
  .command('add <name> <query...>')
117
135
  .description('Add a subscription')
@@ -122,7 +140,7 @@ sub
122
140
  console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
123
141
  process.exit(1);
124
142
  }
125
- const { addSubscription } = await import('./db/subscriptions.js');
143
+ const { addSubscription, listSubscriptions } = await import('./db/subscriptions.js');
126
144
  try {
127
145
  addSubscription(name, queryParts.join(' '), minPoints);
128
146
  console.log(`added '${name}'`);
@@ -131,6 +149,10 @@ sub
131
149
  console.error(err.message);
132
150
  process.exit(1);
133
151
  }
152
+ const { hasScheduledJob } = await import('./lib/schedule.js');
153
+ if (listSubscriptions().length === 1 && !hasScheduledJob()) {
154
+ await promptInstallSchedule();
155
+ }
134
156
  });
135
157
  sub
136
158
  .command('list')
@@ -161,6 +183,34 @@ sub
161
183
  }
162
184
  console.log(`removed '${name}'`);
163
185
  });
186
+ const schedule = program.command('schedule').description('Manage the cron job that runs `hn watch --once`');
187
+ schedule
188
+ .command('status')
189
+ .description('Show whether the watch cron job is installed')
190
+ .action(async () => {
191
+ const { hasScheduledJob } = await import('./lib/schedule.js');
192
+ console.log(hasScheduledJob() ? 'installed' : 'not installed');
193
+ });
194
+ schedule
195
+ .command('install')
196
+ .description('Install the watch cron job (no-op if already installed)')
197
+ .action(async () => {
198
+ const { installScheduledJob } = await import('./lib/schedule.js');
199
+ try {
200
+ console.log(`installed: ${installScheduledJob()}`);
201
+ }
202
+ catch {
203
+ console.error("couldn't find 'hn' on PATH; add the cron line yourself (see README)");
204
+ process.exit(1);
205
+ }
206
+ });
207
+ schedule
208
+ .command('remove')
209
+ .description('Remove the watch cron job')
210
+ .action(async () => {
211
+ const { removeScheduledJob } = await import('./lib/schedule.js');
212
+ console.log(removeScheduledJob() ? 'removed' : 'not installed');
213
+ });
164
214
  program
165
215
  .command('watch')
166
216
  .description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
@@ -0,0 +1,53 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const MARKER = '# hn-bits watch';
5
+ function watchLogPath() {
6
+ return join(homedir(), '.local', 'share', 'hn-bits', 'watch.log');
7
+ }
8
+ function readCrontab() {
9
+ try {
10
+ return execSync('crontab -l', { encoding: 'utf-8' });
11
+ }
12
+ catch {
13
+ return '';
14
+ }
15
+ }
16
+ function writeCrontab(content) {
17
+ execSync('crontab -', { input: content, encoding: 'utf-8' });
18
+ }
19
+ function resolveHnPath() {
20
+ return execSync('which hn', { encoding: 'utf-8' }).trim();
21
+ }
22
+ function resolveNodePath() {
23
+ return execSync('which node', { encoding: 'utf-8' }).trim();
24
+ }
25
+ /** Invokes node directly rather than relying on hn's `#!/usr/bin/env node` shebang, since cron's
26
+ * minimal PATH (unlike an interactive shell) usually can't resolve a version-manager-installed node. */
27
+ function buildCronLine(nodePath, hnPath) {
28
+ return `*/30 * * * * ${nodePath} ${hnPath} watch --once >> ${watchLogPath()} 2>&1 ${MARKER}`;
29
+ }
30
+ export function hasScheduledJob() {
31
+ return readCrontab()
32
+ .split('\n')
33
+ .some((line) => line.includes(MARKER));
34
+ }
35
+ /** Idempotent: no-ops if a job is already installed. Throws if `hn` isn't on PATH. */
36
+ export function installScheduledJob() {
37
+ const current = readCrontab();
38
+ const existing = current.split('\n').find((line) => line.includes(MARKER));
39
+ if (existing)
40
+ return existing;
41
+ const line = buildCronLine(resolveNodePath(), resolveHnPath());
42
+ const body = current.trim().length > 0 ? `${current.replace(/\n+$/, '')}\n${line}\n` : `${line}\n`;
43
+ writeCrontab(body);
44
+ return line;
45
+ }
46
+ export function removeScheduledJob() {
47
+ const lines = readCrontab().split('\n');
48
+ const filtered = lines.filter((line) => !line.includes(MARKER));
49
+ if (filtered.length === lines.length)
50
+ return false;
51
+ writeCrontab(filtered.join('\n'));
52
+ return true;
53
+ }
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import { searchRecent } from '../api/algolia.js';
5
- import { addSubscription, updateSubscription } from '../db/subscriptions.js';
5
+ import { addSubscription, listSubscriptions, updateSubscription } from '../db/subscriptions.js';
6
6
  import { truncateTitle } from '../lib/format.js';
7
+ import { hasScheduledJob, installScheduledJob } from '../lib/schedule.js';
7
8
  import { useTheme } from './theme.js';
8
9
  const PREVIEW_DEBOUNCE_MS = 300;
9
10
  const PREVIEW_WINDOW_DAYS = 7;
@@ -21,6 +22,7 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
21
22
  const [field, setField] = useState('name');
22
23
  const [error, setError] = useState('');
23
24
  const [preview, setPreview] = useState([]);
25
+ const [scheduleConfirm, setScheduleConfirm] = useState(false);
24
26
  const debounceRef = useRef(null);
25
27
  const previewToken = useRef(0);
26
28
  useEffect(() => {
@@ -63,13 +65,36 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
63
65
  addSubscription(trimmedName, trimmedQuery, points);
64
66
  else
65
67
  updateSubscription(subscription.id, { name: trimmedName, query: trimmedQuery, minPoints: points });
68
+ if (mode === 'add' && listSubscriptions().length === 1 && !hasScheduledJob()) {
69
+ setScheduleConfirm(true);
70
+ return;
71
+ }
66
72
  onSave();
67
73
  }
68
74
  catch (err) {
69
75
  setError(err.message);
70
76
  }
71
77
  }
78
+ function resolveScheduleConfirm(install) {
79
+ if (install) {
80
+ try {
81
+ installScheduledJob();
82
+ }
83
+ catch {
84
+ // hn not on PATH; user can run `hn schedule install` later
85
+ }
86
+ }
87
+ setScheduleConfirm(false);
88
+ onSave();
89
+ }
72
90
  function handleInput(input, key) {
91
+ if (scheduleConfirm) {
92
+ if (input === 'y')
93
+ return resolveScheduleConfirm(true);
94
+ if (input === 'n' || key.escape)
95
+ return resolveScheduleConfirm(false);
96
+ return;
97
+ }
73
98
  if (key.escape)
74
99
  return onCancel();
75
100
  if (key.tab) {
@@ -100,5 +125,5 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
100
125
  useInput(handleInput);
101
126
  const cursor = (f) => (field === f ? '▏' : '');
102
127
  const previewLabel = Number(minPoints) > 0 ? `≥${minPoints} pts` : 'any points';
103
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" })] }));
128
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), scheduleConfirm ? (_jsx(Text, { dimColor: true, children: "no watch schedule installed yet - install one now (every 30 min)? y/n" })) : (_jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" }))] }));
104
129
  }
@@ -4,10 +4,12 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
5
5
  import { formatAge } from '../lib/format.js';
6
6
  import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
7
+ import { hasScheduledJob, installScheduledJob, removeScheduledJob } from '../lib/schedule.js';
7
8
  import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
8
9
  import { footerRows, SUBS_KEYS } from './keymap.js';
9
10
  import { HEADER_ROWS } from './Layout.js';
10
11
  import { useTheme } from './theme.js';
12
+ import { useFlash } from './useFlash.js';
11
13
  function pointsLabel(minPoints) {
12
14
  return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
13
15
  }
@@ -18,6 +20,8 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
18
20
  const [subs, setSubs] = useState(() => listSubscriptions());
19
21
  const [selected, setSelected] = useState(0);
20
22
  const [deleteConfirm, setDeleteConfirm] = useState(null);
23
+ const [scheduleInstalled, setScheduleInstalled] = useState(() => hasScheduledJob());
24
+ const [scheduleFlash, flashSchedule] = useFlash();
21
25
  const pendingTopJump = useRef(false);
22
26
  const topLineRef = useRef(0);
23
27
  function reload() {
@@ -25,6 +29,22 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
25
29
  setSubs(next);
26
30
  setSelected((s) => clampSelection(s, 0, next.length));
27
31
  }
32
+ function toggleSchedule() {
33
+ if (scheduleInstalled) {
34
+ removeScheduledJob();
35
+ setScheduleInstalled(false);
36
+ flashSchedule('schedule removed');
37
+ return;
38
+ }
39
+ try {
40
+ installScheduledJob();
41
+ setScheduleInstalled(true);
42
+ flashSchedule('schedule installed');
43
+ }
44
+ catch {
45
+ flashSchedule("couldn't find 'hn' on PATH");
46
+ }
47
+ }
28
48
  function handleInput(input, key) {
29
49
  if (deleteConfirm) {
30
50
  if (input === 'y') {
@@ -61,12 +81,17 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
61
81
  return onEdit(subs[selected]);
62
82
  if (input === 'd' && subs[selected])
63
83
  return setDeleteConfirm(subs[selected].name);
84
+ if (input === 'c')
85
+ return toggleSchedule();
64
86
  }
65
87
  useInput(handleInput);
88
+ const scheduleStatusText = scheduleInstalled ? 'schedule: installed (every 30 min)' : 'schedule: not installed';
66
89
  if (subs.length === 0) {
67
- return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
90
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
68
91
  }
69
- const statusLineHeight = deleteConfirm ? 1 : 0;
92
+ const deleteLineHeight = deleteConfirm ? 1 : 0;
93
+ const scheduleFlashLineHeight = scheduleFlash ? 1 : 0;
94
+ const statusLineHeight = 1 + deleteLineHeight + scheduleFlashLineHeight;
70
95
  const listHeight = Math.max(1, bodyHeight - statusLineHeight);
71
96
  const heights = subs.map(() => 1);
72
97
  const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
@@ -79,5 +104,5 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
79
104
  const marker = isSelected ? theme.glyphs.selection : ' ';
80
105
  const background = isSelected ? theme.colors.selectionBackground : undefined;
81
106
  return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ", pointsLabel(sub.minPoints).padEnd(9), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
82
- }), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
107
+ }), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
83
108
  }
package/dist/ui/keymap.js CHANGED
@@ -49,6 +49,7 @@ export const SUBS_KEYS = [
49
49
  { key: 'a', label: 'add' },
50
50
  { key: 'e', label: 'edit' },
51
51
  { key: 'd', label: 'delete' },
52
+ { key: 'c', label: 'schedule' },
52
53
  ];
53
54
  export const SUB_MATCHES_KEYS = [
54
55
  { key: 'j/k', label: 'move' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {