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 +12 -1
- package/dist/index.js +51 -1
- package/dist/lib/schedule.js +53 -0
- package/dist/ui/SubscriptionForm.js +27 -2
- package/dist/ui/SubscriptionsView.js +28 -3
- package/dist/ui/keymap.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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