@1a35e1/sonar-cli 0.1.2 → 0.2.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
@@ -20,7 +20,7 @@ This cli has been designed to handover indexing and consumption to agents.
20
20
  Install the CLI
21
21
 
22
22
  ```sh
23
- pnpm add -g @1a35e1/sonar-cli
23
+ pnpm add -g @1a35e1/sonar-cli@latest
24
24
  ```
25
25
 
26
26
  Register your API key.
@@ -0,0 +1,74 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { existsSync, mkdirSync } from 'node:fs';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import Database from 'better-sqlite3';
8
+ import { DB_PATH } from '../../../lib/db.js';
9
+ import { integrityCheck } from './utils.js';
10
+ export const options = zod.object({
11
+ out: zod.string().optional().describe('Backup output path (default: ~/.sonar/data-backup-<timestamp>.db)'),
12
+ json: zod.boolean().default(false).describe('Raw JSON output'),
13
+ });
14
+ function ts() {
15
+ const d = new Date();
16
+ const p = (n) => String(n).padStart(2, '0');
17
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
18
+ }
19
+ export default function DataBackup({ options: flags }) {
20
+ const [error, setError] = useState(null);
21
+ useEffect(() => {
22
+ async function run() {
23
+ try {
24
+ if (!existsSync(DB_PATH))
25
+ throw new Error(`source database not found: ${DB_PATH}`);
26
+ // Use trimmed value for the actual output path to avoid confusing
27
+ // filesystem errors from leading/trailing whitespace.
28
+ const trimmedOut = flags.out?.trim();
29
+ const out = trimmedOut && trimmedOut.length > 0
30
+ ? trimmedOut
31
+ : join(dirname(DB_PATH), `${basename(DB_PATH, '.db')}-backup-${ts()}.db`);
32
+ mkdirSync(dirname(out), { recursive: true });
33
+ // Use SQLite's online backup API (better-sqlite3 wraps the C-level
34
+ // sqlite3_backup_* functions) instead of a plain filesystem copy.
35
+ // This works correctly under concurrent writes: it iterates over DB
36
+ // pages in a consistent snapshot without requiring an exclusive lock
37
+ // and without needing a prior WAL checkpoint.
38
+ const db = new Database(DB_PATH);
39
+ try {
40
+ await db.backup(out);
41
+ }
42
+ finally {
43
+ db.close();
44
+ }
45
+ const check = integrityCheck(out);
46
+ if (check !== 'ok')
47
+ throw new Error(`backup integrity check failed: ${check}`);
48
+ const result = { ok: true, source: DB_PATH, backup: out };
49
+ if (flags.json) {
50
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
51
+ }
52
+ else {
53
+ process.stdout.write(`Backup complete: ${out}\n`);
54
+ }
55
+ process.exit(0);
56
+ }
57
+ catch (e) {
58
+ setError(e instanceof Error ? e.message : String(e));
59
+ }
60
+ }
61
+ run();
62
+ }, []);
63
+ useEffect(() => {
64
+ if (!error)
65
+ return;
66
+ if (flags.json) {
67
+ process.stderr.write(`${error}\n`);
68
+ process.exit(1);
69
+ }
70
+ }, [error, flags.json]);
71
+ if (error)
72
+ return flags.json ? _jsx(_Fragment, {}) : _jsxs(Text, { color: "red", children: ["Error: ", error] });
73
+ return flags.json ? _jsx(_Fragment, {}) : _jsx(Text, { dimColor: true, children: "Creating backup..." });
74
+ }
@@ -0,0 +1,86 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { DB_PATH } from '../../../lib/db.js';
8
+ import { integrityCheck, copyDbWithSidecars } from './utils.js';
9
+ export const options = zod.object({
10
+ from: zod.string().describe('Backup database path to restore from'),
11
+ to: zod.string().optional().describe('Target database path (default: local sonar DB path)'),
12
+ json: zod.boolean().default(false).describe('Raw JSON output'),
13
+ });
14
+ export default function DataRestore({ options: flags }) {
15
+ const [error, setError] = useState(null);
16
+ useEffect(() => {
17
+ try {
18
+ const src = resolve(flags.from);
19
+ const dst = resolve(flags.to ?? DB_PATH);
20
+ // Guard: prevent copying a file onto itself, which would corrupt the DB.
21
+ if (src === dst) {
22
+ throw new Error(`Source and destination resolve to the same path: ${src}\n` +
23
+ 'Specify a different --to path.');
24
+ }
25
+ if (!existsSync(src))
26
+ throw new Error(`backup not found: ${src}`);
27
+ // Verify the backup is healthy before touching anything.
28
+ const srcCheck = integrityCheck(src);
29
+ if (srcCheck !== 'ok')
30
+ throw new Error(`backup integrity check failed: ${srcCheck}`);
31
+ mkdirSync(dirname(dst), { recursive: true });
32
+ // Snapshot the current DB — including WAL/SHM sidecars — so we have
33
+ // a complete, self-consistent point-in-time snapshot to roll back to if
34
+ // anything goes wrong during the restore.
35
+ const preRestore = existsSync(dst) ? `${dst}.pre-restore.${Date.now()}` : null;
36
+ if (preRestore) {
37
+ copyDbWithSidecars(dst, preRestore);
38
+ }
39
+ // Copy backup → destination (main DB + any sidecars).
40
+ copyDbWithSidecars(src, dst);
41
+ // Verify the restored DB before declaring success.
42
+ const dstCheck = integrityCheck(dst);
43
+ if (dstCheck !== 'ok') {
44
+ // The restored file is corrupt. Roll back to the pre-restore snapshot
45
+ // so we don't leave the user with a broken local database.
46
+ if (preRestore && existsSync(preRestore)) {
47
+ copyDbWithSidecars(preRestore, dst);
48
+ for (const ext of ['-wal', '-shm']) {
49
+ rmSync(`${preRestore}${ext}`, { force: true });
50
+ }
51
+ rmSync(preRestore, { force: true });
52
+ throw new Error(`Restored database failed integrity check (${dstCheck}). ` +
53
+ 'Rolled back to the previous database — your data is intact.');
54
+ }
55
+ throw new Error(`restored database integrity check failed: ${dstCheck}`);
56
+ }
57
+ // Clean up the pre-restore snapshot on success.
58
+ if (preRestore) {
59
+ for (const ext of ['-wal', '-shm']) {
60
+ rmSync(`${preRestore}${ext}`, { force: true });
61
+ }
62
+ rmSync(preRestore, { force: true });
63
+ }
64
+ const result = { ok: true, from: src, to: dst };
65
+ if (flags.json)
66
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
67
+ else
68
+ process.stdout.write(`Restore complete: ${src} -> ${dst}\n`);
69
+ process.exit(0);
70
+ }
71
+ catch (e) {
72
+ setError(e instanceof Error ? e.message : String(e));
73
+ }
74
+ }, []);
75
+ useEffect(() => {
76
+ if (!error)
77
+ return;
78
+ if (flags.json) {
79
+ process.stderr.write(`${error}\n`);
80
+ process.exit(1);
81
+ }
82
+ }, [error, flags.json]);
83
+ if (error)
84
+ return flags.json ? _jsx(_Fragment, {}) : _jsxs(Text, { color: "red", children: ["Error: ", error] });
85
+ return flags.json ? _jsx(_Fragment, {}) : _jsx(Text, { dimColor: true, children: "Restoring database..." });
86
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared utilities for the data backup/restore/verify commands.
3
+ */
4
+ import { copyFileSync, existsSync, rmSync } from 'node:fs';
5
+ import Database from 'better-sqlite3';
6
+ /**
7
+ * Run SQLite's built-in integrity_check pragma on the given database file.
8
+ * Returns `'ok'` when the database is healthy.
9
+ *
10
+ * The DB handle is always closed — even when the pragma throws — so callers
11
+ * never have to worry about leaked file descriptors.
12
+ */
13
+ export function integrityCheck(path) {
14
+ const db = new Database(path, { readonly: true });
15
+ try {
16
+ const rows = db.pragma('integrity_check');
17
+ const first = Object.values(rows[0] ?? {})[0];
18
+ return String(first ?? 'unknown');
19
+ }
20
+ finally {
21
+ db.close();
22
+ }
23
+ }
24
+ /**
25
+ * Copy a SQLite DB file together with any WAL / SHM sidecars that exist.
26
+ * If a sidecar does not exist at the source it is removed from the destination
27
+ * (so that the destination remains self-consistent).
28
+ */
29
+ export function copyDbWithSidecars(src, dst) {
30
+ copyFileSync(src, dst);
31
+ for (const ext of ['-wal', '-shm']) {
32
+ if (existsSync(`${src}${ext}`)) {
33
+ copyFileSync(`${src}${ext}`, `${dst}${ext}`);
34
+ }
35
+ else {
36
+ rmSync(`${dst}${ext}`, { force: true });
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,44 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { existsSync } from 'node:fs';
6
+ import { DB_PATH } from '../../../lib/db.js';
7
+ import { integrityCheck } from './utils.js';
8
+ export const options = zod.object({
9
+ path: zod.string().optional().describe('Database path (default: local sonar DB path)'),
10
+ json: zod.boolean().default(false).describe('Raw JSON output'),
11
+ });
12
+ export default function DataVerify({ options: flags }) {
13
+ const [error, setError] = useState(null);
14
+ useEffect(() => {
15
+ try {
16
+ const path = flags.path ?? DB_PATH;
17
+ if (!existsSync(path))
18
+ throw new Error(`database not found: ${path}`);
19
+ const result = integrityCheck(path);
20
+ const ok = result === 'ok';
21
+ if (flags.json) {
22
+ process.stdout.write(`${JSON.stringify({ ok, path, integrity: result }, null, 2)}\n`);
23
+ }
24
+ else {
25
+ process.stdout.write(ok ? `Integrity check passed: ${path}\n` : `Integrity check failed: ${path} (${result})\n`);
26
+ }
27
+ process.exit(ok ? 0 : 1);
28
+ }
29
+ catch (e) {
30
+ setError(e instanceof Error ? e.message : String(e));
31
+ }
32
+ }, []);
33
+ useEffect(() => {
34
+ if (!error)
35
+ return;
36
+ if (flags.json) {
37
+ process.stderr.write(`${error}\n`);
38
+ process.exit(1);
39
+ }
40
+ }, [error, flags.json]);
41
+ if (error)
42
+ return flags.json ? _jsx(_Fragment, {}) : _jsxs(Text, { color: "red", children: ["Error: ", error] });
43
+ return flags.json ? _jsx(_Fragment, {}) : _jsx(Text, { dimColor: true, children: "Verifying database..." });
44
+ }
@@ -36,6 +36,19 @@ export default function Feed({ options: flags }) {
36
36
  kind: flags.kind ?? 'default',
37
37
  });
38
38
  if (flags.json) {
39
+ if (result.feed.length === 0) {
40
+ const kind = flags.kind ?? 'default';
41
+ process.stderr.write([
42
+ '[sonar feed] Empty result — possible causes:',
43
+ kind === 'bookmarks'
44
+ ? ' • No bookmarks ingested yet. Run: sonar ingest bookmarks'
45
+ : ` • No tweets matched your interests in the last ${windowLabel(flags.hours, flags.days)}.`,
46
+ ' • Check interests are configured: sonar interests',
47
+ ' • Ingest may be stale: sonar ingest tweets && sonar ingest monitor',
48
+ ' • Widen the window: sonar feed --hours 48',
49
+ ' • Account/quota status: sonar account',
50
+ ].join('\n') + '\n');
51
+ }
39
52
  process.stdout.write(`${JSON.stringify(result.feed, null, 2)}\n`);
40
53
  process.exit(0);
41
54
  }
@@ -54,7 +67,8 @@ export default function Feed({ options: flags }) {
54
67
  return _jsx(Spinner, { label: "Fetching feed..." });
55
68
  }
56
69
  if (data.length === 0) {
57
- return _jsx(Text, { dimColor: true, children: "No tweets found in this window." });
70
+ const kind = flags.kind ?? 'default';
71
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "yellow", children: "No tweets found." }), kind === 'bookmarks' ? (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: "Your bookmarks feed is empty. Things to check:" }), _jsxs(Text, { dimColor: true, children: [" 1. Ingest bookmarks first: ", _jsx(Text, { color: "cyan", children: "sonar ingest bookmarks" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Then monitor progress: ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor --watch" })] })] })) : (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Text, { dimColor: true, children: ["No ", kind === 'followers' ? 'follower' : kind === 'following' ? 'following' : 'network', " tweets matched your interests in the last ", _jsx(Text, { color: "white", children: windowLabel(flags.hours, flags.days) }), "."] }), _jsx(Text, { dimColor: true, children: "Things to check:" }), _jsxs(Text, { dimColor: true, children: [" 1. Widen the window: ", _jsx(Text, { color: "cyan", children: "sonar feed --hours 48" }), " or ", _jsx(Text, { color: "cyan", children: "--days 7" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Check interests exist: ", _jsx(Text, { color: "cyan", children: "sonar interests" })] }), _jsxs(Text, { dimColor: true, children: [" 3. Trigger ingest if stale: ", _jsx(Text, { color: "cyan", children: "sonar ingest tweets" })] }), _jsxs(Text, { dimColor: true, children: [" 4. Check ingest progress: ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor" })] }), _jsxs(Text, { dimColor: true, children: [" 5. Run matching: ", _jsx(Text, { color: "cyan", children: "sonar interests match" })] })] })), _jsxs(Text, { dimColor: true, children: ["Account status and quota: ", _jsx(Text, { color: "cyan", children: "sonar account" })] })] }));
58
72
  }
59
73
  if (flags.interactive) {
60
74
  return _jsx(InteractiveFeedSession, { items: data, vendor: getVendor(flags.vendor) });
@@ -56,6 +56,16 @@ export default function Inbox({ options: flags }) {
56
56
  limit: flags.limit,
57
57
  });
58
58
  if (flags.json) {
59
+ if (result.suggestions.length === 0) {
60
+ const statusLabel = flags.all ? 'all statuses' : (flags.status ?? 'inbox');
61
+ process.stderr.write([
62
+ `[sonar inbox] Empty result for status=${statusLabel} — possible causes:`,
63
+ ' • No interests defined. Run: sonar interests create --from-prompt "..."',
64
+ ' • Ingest and matching have not run. Run: sonar ingest tweets && sonar interests match',
65
+ ' • All inbox items were already actioned. Try: sonar inbox --all',
66
+ ' • Account/quota issue. Run: sonar account',
67
+ ].join('\n') + '\n');
68
+ }
59
69
  process.stdout.write(JSON.stringify(result.suggestions, null, 2) + '\n');
60
70
  process.exit(0);
61
71
  }
@@ -74,7 +84,8 @@ export default function Inbox({ options: flags }) {
74
84
  return _jsx(Spinner, { label: "Fetching inbox..." });
75
85
  }
76
86
  if (data.length === 0) {
77
- return _jsx(Text, { dimColor: true, children: "Inbox is empty." });
87
+ const statusLabel = flags.all ? 'all statuses' : (flags.status ?? 'inbox');
88
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "yellow", children: ["Inbox is empty", statusLabel !== 'all statuses' ? ` (status: ${statusLabel})` : '', "."] }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: "Things to check:" }), flags.status && !flags.all && (_jsxs(Text, { dimColor: true, children: [" 1. Broaden scope: ", _jsx(Text, { color: "cyan", children: "sonar inbox --all" })] })), _jsxs(Text, { dimColor: true, children: [" ", flags.status && !flags.all ? '2' : '1', ". Interests defined? ", _jsx(Text, { color: "cyan", children: "sonar interests" })] }), _jsxs(Text, { dimColor: true, children: [" ", flags.status && !flags.all ? '3' : '2', ". Ingest recent tweets: ", _jsx(Text, { color: "cyan", children: "sonar ingest tweets" })] }), _jsxs(Text, { dimColor: true, children: [" ", flags.status && !flags.all ? '4' : '3', ". Run interest matching: ", _jsx(Text, { color: "cyan", children: "sonar interests match" })] }), _jsxs(Text, { dimColor: true, children: [" ", flags.status && !flags.all ? '5' : '4', ". Monitor job progress: ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor" })] })] }), _jsxs(Text, { dimColor: true, children: ["Account status and quota: ", _jsx(Text, { color: "cyan", children: "sonar account" })] })] }));
78
89
  }
79
90
  if (flags.interactive) {
80
91
  return _jsx(InteractiveInboxSession, { items: data, vendor: getVendor(flags.vendor) });
@@ -1,13 +1,28 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { gql } from '../../lib/client.js';
5
5
  import { Spinner } from '../../components/Spinner.js';
6
6
  import { RefreshTip } from '../../components/RefreshTip.js';
7
+ /** How long (ms) to wait for the ingest mutation before giving up. */
8
+ const INGEST_TIMEOUT_MS = 15_000;
7
9
  export default function IndexBookmarks() {
8
10
  const [queued, setQueued] = useState(null);
9
11
  const [error, setError] = useState(null);
12
+ const [timedOut, setTimedOut] = useState(false);
13
+ const deadlineRef = useRef(null);
10
14
  useEffect(() => {
15
+ // Hard wall-clock timeout — catches cases where the gql call itself
16
+ // hangs (e.g. server accepts the connection but never sends a response).
17
+ deadlineRef.current = setTimeout(() => {
18
+ setTimedOut(true);
19
+ setError(`Ingest trigger timed out after ${INGEST_TIMEOUT_MS / 1000}s.\n` +
20
+ 'The server accepted the request but did not respond in time.\n' +
21
+ 'Next steps:\n' +
22
+ ' • Run "sonar ingest monitor" — the job may still be queued\n' +
23
+ ' • Check SONAR_API_URL points to the correct endpoint\n' +
24
+ ' • Verify the server is healthy and retry');
25
+ }, INGEST_TIMEOUT_MS);
11
26
  async function run() {
12
27
  try {
13
28
  const res = await gql(`
@@ -15,17 +30,26 @@ export default function IndexBookmarks() {
15
30
  indexBookmarks
16
31
  }
17
32
  `);
33
+ if (deadlineRef.current)
34
+ clearTimeout(deadlineRef.current);
18
35
  setQueued(res.indexBookmarks);
19
36
  }
20
37
  catch (err) {
38
+ if (deadlineRef.current)
39
+ clearTimeout(deadlineRef.current);
21
40
  setError(err instanceof Error ? err.message : String(err));
22
41
  }
23
42
  }
24
43
  run();
44
+ return () => {
45
+ if (deadlineRef.current)
46
+ clearTimeout(deadlineRef.current);
47
+ };
25
48
  }, []);
26
- if (error)
27
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
49
+ if (error) {
50
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: timedOut ? 'yellow' : 'red', children: [timedOut ? '⚠ ' : 'Error: ', error] }), timedOut && (_jsxs(Text, { dimColor: true, children: ["Tip: run ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor" }), " to check whether the job was queued despite the timeout."] }))] }));
51
+ }
28
52
  if (queued === null)
29
53
  return _jsx(Spinner, { label: "Triggering bookmark indexing..." });
30
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "index_bookmarks: " }), _jsx(Text, { children: queued ? '✓ queued' : '✗ failed' })] }), _jsx(RefreshTip, {})] }));
54
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "index_bookmarks: " }), _jsx(Text, { children: queued ? '✓ queued' : '✗ failed to queue — check server logs' })] }), !queued && (_jsxs(Text, { dimColor: true, children: ["The server returned false. Verify your API key and account status with ", _jsx(Text, { color: "cyan", children: "sonar account" }), "."] })), _jsx(RefreshTip, {})] }));
31
55
  }
@@ -1,13 +1,28 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { gql } from '../../lib/client.js';
5
5
  import { Spinner } from '../../components/Spinner.js';
6
6
  import { RefreshTip } from '../../components/RefreshTip.js';
7
+ /** How long (ms) to wait for the ingest mutation before giving up. */
8
+ const INGEST_TIMEOUT_MS = 15_000;
7
9
  export default function IndexTweets() {
8
10
  const [queued, setQueued] = useState(null);
9
11
  const [error, setError] = useState(null);
12
+ const [timedOut, setTimedOut] = useState(false);
13
+ const deadlineRef = useRef(null);
10
14
  useEffect(() => {
15
+ // Hard wall-clock timeout — catches cases where the gql call itself
16
+ // hangs (e.g. server accepts the connection but never sends a response).
17
+ deadlineRef.current = setTimeout(() => {
18
+ setTimedOut(true);
19
+ setError(`Ingest trigger timed out after ${INGEST_TIMEOUT_MS / 1000}s.\n` +
20
+ 'The server accepted the request but did not respond in time.\n' +
21
+ 'Next steps:\n' +
22
+ ' • Run "sonar ingest monitor" — the job may still be queued\n' +
23
+ ' • Check SONAR_API_URL points to the correct endpoint\n' +
24
+ ' • Verify the server is healthy and retry');
25
+ }, INGEST_TIMEOUT_MS);
11
26
  async function run() {
12
27
  try {
13
28
  const res = await gql(`
@@ -15,17 +30,26 @@ export default function IndexTweets() {
15
30
  indexTweets
16
31
  }
17
32
  `);
33
+ if (deadlineRef.current)
34
+ clearTimeout(deadlineRef.current);
18
35
  setQueued(res.indexTweets);
19
36
  }
20
37
  catch (err) {
38
+ if (deadlineRef.current)
39
+ clearTimeout(deadlineRef.current);
21
40
  setError(err instanceof Error ? err.message : String(err));
22
41
  }
23
42
  }
24
43
  run();
44
+ return () => {
45
+ if (deadlineRef.current)
46
+ clearTimeout(deadlineRef.current);
47
+ };
25
48
  }, []);
26
- if (error)
27
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
49
+ if (error) {
50
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: timedOut ? 'yellow' : 'red', children: [timedOut ? '⚠ ' : 'Error: ', error] }), timedOut && (_jsxs(Text, { dimColor: true, children: ["Tip: run ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor" }), " to check whether the job was queued despite the timeout."] }))] }));
51
+ }
28
52
  if (queued === null)
29
53
  return _jsx(Spinner, { label: "Triggering tweet indexing..." });
30
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "index_tweets: " }), _jsx(Text, { children: queued ? '✓ queued' : '✗ failed' })] }), _jsx(RefreshTip, {})] }));
54
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "index_tweets: " }), _jsx(Text, { children: queued ? '✓ queued' : '✗ failed to queue — check server logs' })] }), !queued && (_jsxs(Text, { dimColor: true, children: ["The server returned false. Verify your API key and account status with ", _jsx(Text, { color: "cyan", children: "sonar account" }), "."] })), _jsx(RefreshTip, {})] }));
31
55
  }
@@ -1,9 +1,9 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import zod from 'zod';
4
4
  import { Box, Text } from 'ink';
5
5
  import { gql } from '../../lib/client.js';
6
- import { generateInterest } from '../../lib/ai.js';
6
+ import { generateInterest, OPENAI_TIMEOUT_MS, ANTHROPIC_TIMEOUT_MS } from '../../lib/ai.js';
7
7
  import { getVendor } from '../../lib/config.js';
8
8
  import { Spinner } from '../../components/Spinner.js';
9
9
  export const options = zod.object({
@@ -44,6 +44,12 @@ const CREATE_MUTATION = `
44
44
  export default function InterestsCreate({ options: flags }) {
45
45
  const [data, setData] = useState(null);
46
46
  const [error, setError] = useState(null);
47
+ useEffect(() => {
48
+ if (!error || !flags.json)
49
+ return;
50
+ process.stderr.write(`${error}\n`);
51
+ process.exit(1);
52
+ }, [error, flags.json]);
47
53
  useEffect(() => {
48
54
  async function run() {
49
55
  try {
@@ -82,11 +88,18 @@ export default function InterestsCreate({ options: flags }) {
82
88
  }
83
89
  run();
84
90
  }, []);
85
- if (error)
91
+ if (error) {
92
+ if (flags.json)
93
+ return _jsx(_Fragment, {});
86
94
  return _jsxs(Text, { color: "red", children: ["Error: ", error] });
95
+ }
87
96
  if (!data) {
97
+ if (flags.json)
98
+ return _jsx(_Fragment, {});
99
+ const vendor = getVendor(flags.vendor);
100
+ const timeoutSec = (vendor === 'openai' ? OPENAI_TIMEOUT_MS : ANTHROPIC_TIMEOUT_MS) / 1000;
88
101
  const label = flags.fromPrompt
89
- ? `Generating interest via ${getVendor(flags.vendor)}...`
102
+ ? `Generating interest via ${vendor}... (may take up to ${timeoutSec}s${vendor === 'openai' ? ' with web search' : ''})`
90
103
  : 'Creating interest...';
91
104
  return _jsx(Spinner, { label: label });
92
105
  }
@@ -1,9 +1,9 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import zod from 'zod';
4
4
  import { Box, Text } from 'ink';
5
5
  import { gql } from '../../lib/client.js';
6
- import { generateInterest } from '../../lib/ai.js';
6
+ import { generateInterest, OPENAI_TIMEOUT_MS, ANTHROPIC_TIMEOUT_MS } from '../../lib/ai.js';
7
7
  import { getVendor } from '../../lib/config.js';
8
8
  import { Spinner } from '../../components/Spinner.js';
9
9
  export const options = zod.object({
@@ -70,6 +70,12 @@ async function fetchById(id) {
70
70
  export default function InterestsUpdate({ options: flags }) {
71
71
  const [data, setData] = useState(null);
72
72
  const [error, setError] = useState(null);
73
+ useEffect(() => {
74
+ if (!error || !flags.json)
75
+ return;
76
+ process.stderr.write(`${error}\n`);
77
+ process.exit(1);
78
+ }, [error, flags.json]);
73
79
  useEffect(() => {
74
80
  async function run() {
75
81
  try {
@@ -128,14 +134,19 @@ export default function InterestsUpdate({ options: flags }) {
128
134
  }
129
135
  run();
130
136
  }, []);
131
- if (error)
137
+ if (error) {
138
+ if (flags.json)
139
+ return _jsx(_Fragment, {});
132
140
  return _jsxs(Text, { color: "red", children: ["Error: ", error] });
141
+ }
133
142
  if (!data) {
143
+ if (flags.json)
144
+ return _jsx(_Fragment, {});
145
+ const vendor = getVendor(flags.vendor);
146
+ const timeoutSec = (vendor === 'openai' ? OPENAI_TIMEOUT_MS : ANTHROPIC_TIMEOUT_MS) / 1000;
134
147
  const label = flags.fromPrompt
135
- ? `Generating interest via ${getVendor(flags.vendor)}...`
136
- : (flags.addKeywords || flags.removeKeywords || flags.addTopics || flags.removeTopics)
137
- ? 'Updating interest...'
138
- : 'Updating interest...';
148
+ ? `Generating interest via ${vendor}... (may take up to ${timeoutSec}s${vendor === 'openai' ? ' with web search' : ''})`
149
+ : 'Updating interest...';
139
150
  return _jsx(Spinner, { label: label });
140
151
  }
141
152
  return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 updated"] })] }), data.description && _jsx(Text, { dimColor: true, children: data.description }), data.keywords && data.keywords.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "keywords:" }), _jsx(Text, { children: data.keywords.join(', ') })] })), data.relatedTopics && data.relatedTopics.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "topics: " }), _jsx(Text, { children: data.relatedTopics.join(', ') })] }))] }));
@@ -24,9 +24,12 @@ export default function Monitor({ options: flags }) {
24
24
  const token = getToken();
25
25
  const baseUrl = getApiUrl().replace(/\/graphql$/, '');
26
26
  async function fetchStatus() {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), 10_000);
27
29
  try {
28
30
  const [statusRes, meRes] = await Promise.all([
29
31
  fetch(`${baseUrl}/indexing/status`, {
32
+ signal: controller.signal,
30
33
  headers: { Authorization: `Bearer ${token}` },
31
34
  }),
32
35
  gql(`
@@ -46,6 +49,7 @@ export default function Monitor({ options: flags }) {
46
49
  }
47
50
  `),
48
51
  ]);
52
+ clearTimeout(timer);
49
53
  if (!statusRes.ok)
50
54
  throw new Error(`HTTP ${statusRes.status} from ${baseUrl}`);
51
55
  const status = await statusRes.json();
@@ -53,7 +57,15 @@ export default function Monitor({ options: flags }) {
53
57
  setError(null);
54
58
  }
55
59
  catch (err) {
56
- setError(err instanceof Error ? err.message : String(err));
60
+ clearTimeout(timer);
61
+ if (err instanceof DOMException && err.name === 'AbortError') {
62
+ setError('Monitor request timed out (10s). ' +
63
+ 'The server may be overloaded. ' +
64
+ 'Check SONAR_API_URL or retry without --watch.');
65
+ }
66
+ else {
67
+ setError(err instanceof Error ? err.message : String(err));
68
+ }
57
69
  }
58
70
  }
59
71
  fetchStatus();
package/dist/lib/ai.js CHANGED
@@ -27,8 +27,46 @@ Given a user's prompt, expand it into a rich interest profile and return a JSON
27
27
  Optimise every field for semantic density and current relevance, not readability.
28
28
 
29
29
  Respond ONLY with valid JSON, no markdown, no explanation.`;
30
+ // OpenAI uses web_search_preview which can legitimately take 30-60 s.
31
+ export const OPENAI_TIMEOUT_MS = 90_000;
32
+ // Anthropic calls are simpler — 60 s is generous.
33
+ export const ANTHROPIC_TIMEOUT_MS = 60_000;
34
+ /**
35
+ * Wraps fetch() with an AbortController timeout that covers the full response
36
+ * cycle — headers AND body. The processResponse callback receives the Response
37
+ * and is responsible for consuming the body (e.g. calling res.json()). The
38
+ * timer is kept alive until processResponse resolves or rejects, ensuring a
39
+ * stalled body download is caught just like a stalled connection.
40
+ */
41
+ async function fetchWithTimeout(url, init, timeoutMs, vendorLabel, processResponse) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
44
+ try {
45
+ const res = await fetch(url, { ...init, signal: controller.signal });
46
+ return await processResponse(res);
47
+ }
48
+ catch (err) {
49
+ if (err instanceof DOMException && err.name === 'AbortError') {
50
+ const lines = [
51
+ `${vendorLabel} request timed out after ${timeoutMs / 1000}s.`,
52
+ 'Possible causes:',
53
+ ' • The AI provider is overloaded or rate-limiting you',
54
+ ' • Your network connection is slow or unstable',
55
+ ];
56
+ if (vendorLabel.toLowerCase().includes('openai')) {
57
+ lines.push(' • The web_search tool (OpenAI) took longer than usual');
58
+ }
59
+ lines.push('Try again in a moment, or use --vendor to switch providers.');
60
+ throw new Error(lines.join('\n'));
61
+ }
62
+ throw err;
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
30
68
  async function callOpenAI(prompt, apiKey) {
31
- const res = await fetch('https://api.openai.com/v1/responses', {
69
+ return fetchWithTimeout('https://api.openai.com/v1/responses', {
32
70
  method: 'POST',
33
71
  headers: {
34
72
  'Content-Type': 'application/json',
@@ -40,22 +78,23 @@ async function callOpenAI(prompt, apiKey) {
40
78
  instructions: SYSTEM_PROMPT,
41
79
  input: prompt,
42
80
  }),
81
+ }, OPENAI_TIMEOUT_MS, 'OpenAI', async (res) => {
82
+ if (!res.ok) {
83
+ const err = await res.json().catch(() => ({}));
84
+ throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
85
+ }
86
+ const data = await res.json();
87
+ const text = data.output
88
+ ?.filter((b) => b.type === 'message')
89
+ .flatMap((b) => b.content)
90
+ .filter((c) => c.type === 'output_text')
91
+ .map((c) => c.text)
92
+ .join('') ?? '';
93
+ return JSON.parse(extractJSON(text));
43
94
  });
44
- if (!res.ok) {
45
- const err = await res.json().catch(() => ({}));
46
- throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
47
- }
48
- const data = await res.json();
49
- const text = data.output
50
- ?.filter((b) => b.type === 'message')
51
- .flatMap((b) => b.content)
52
- .filter((c) => c.type === 'output_text')
53
- .map((c) => c.text)
54
- .join('') ?? '';
55
- return JSON.parse(extractJSON(text));
56
95
  }
57
96
  async function callAnthropic(prompt, apiKey) {
58
- const res = await fetch('https://api.anthropic.com/v1/messages', {
97
+ return fetchWithTimeout('https://api.anthropic.com/v1/messages', {
59
98
  method: 'POST',
60
99
  headers: {
61
100
  'Content-Type': 'application/json',
@@ -68,20 +107,21 @@ async function callAnthropic(prompt, apiKey) {
68
107
  system: SYSTEM_PROMPT,
69
108
  messages: [{ role: 'user', content: prompt }],
70
109
  }),
110
+ }, ANTHROPIC_TIMEOUT_MS, 'Anthropic', async (res) => {
111
+ if (!res.ok) {
112
+ const err = await res.json().catch(() => ({}));
113
+ throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
114
+ }
115
+ const data = await res.json();
116
+ return JSON.parse(extractJSON(data.content[0].text));
71
117
  });
72
- if (!res.ok) {
73
- const err = await res.json().catch(() => ({}));
74
- throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
75
- }
76
- const data = await res.json();
77
- return JSON.parse(extractJSON(data.content[0].text));
78
118
  }
79
119
  const REPLY_SYSTEM_PROMPT = `You are a concise, contextual tweet reply writer. Write a single reply tweet (max 280 characters) that is natural, engaging, and relevant to the original tweet. Do not use hashtags unless essential. Do not include any explanation or preamble. Return only valid JSON with a single "reply" field containing the reply text.`;
80
120
  async function callOpenAIReply(tweetText, userPrompt, apiKey) {
81
121
  const userContent = userPrompt
82
122
  ? `Original tweet: "${tweetText}"\n\nAngle for reply: ${userPrompt}`
83
123
  : `Original tweet: "${tweetText}"\n\nWrite a thoughtful reply.`;
84
- const res = await fetch('https://api.openai.com/v1/chat/completions', {
124
+ return fetchWithTimeout('https://api.openai.com/v1/chat/completions', {
85
125
  method: 'POST',
86
126
  headers: {
87
127
  'Content-Type': 'application/json',
@@ -94,20 +134,21 @@ async function callOpenAIReply(tweetText, userPrompt, apiKey) {
94
134
  { role: 'user', content: userContent },
95
135
  ],
96
136
  }),
137
+ }, OPENAI_TIMEOUT_MS, 'OpenAI', async (res) => {
138
+ if (!res.ok) {
139
+ const err = await res.json().catch(() => ({}));
140
+ throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
141
+ }
142
+ const data = await res.json();
143
+ const text = data.choices?.[0]?.message?.content ?? '';
144
+ return JSON.parse(extractJSON(text));
97
145
  });
98
- if (!res.ok) {
99
- const err = await res.json().catch(() => ({}));
100
- throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
101
- }
102
- const data = await res.json();
103
- const text = data.choices?.[0]?.message?.content ?? '';
104
- return JSON.parse(extractJSON(text));
105
146
  }
106
147
  async function callAnthropicReply(tweetText, userPrompt, apiKey) {
107
148
  const userContent = userPrompt
108
149
  ? `Original tweet: "${tweetText}"\n\nAngle for reply: ${userPrompt}`
109
150
  : `Original tweet: "${tweetText}"\n\nWrite a thoughtful reply.`;
110
- const res = await fetch('https://api.anthropic.com/v1/messages', {
151
+ return fetchWithTimeout('https://api.anthropic.com/v1/messages', {
111
152
  method: 'POST',
112
153
  headers: {
113
154
  'Content-Type': 'application/json',
@@ -120,13 +161,14 @@ async function callAnthropicReply(tweetText, userPrompt, apiKey) {
120
161
  system: REPLY_SYSTEM_PROMPT,
121
162
  messages: [{ role: 'user', content: userContent }],
122
163
  }),
164
+ }, ANTHROPIC_TIMEOUT_MS, 'Anthropic', async (res) => {
165
+ if (!res.ok) {
166
+ const err = await res.json().catch(() => ({}));
167
+ throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
168
+ }
169
+ const data = await res.json();
170
+ return JSON.parse(extractJSON(data.content[0].text));
123
171
  });
124
- if (!res.ok) {
125
- const err = await res.json().catch(() => ({}));
126
- throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
127
- }
128
- const data = await res.json();
129
- return JSON.parse(extractJSON(data.content[0].text));
130
172
  }
131
173
  export async function generateReply(tweetText, userPrompt, vendor) {
132
174
  if (vendor === 'openai') {
@@ -1,7 +1,18 @@
1
1
  import { getApiUrl, getToken } from './config.js';
2
+ /**
3
+ * Execute a GraphQL request against the Sonar API.
4
+ *
5
+ * A hard timeout (default 20 s) is applied via AbortController so that the
6
+ * process never hangs silently when the server is unresponsive. The timeout
7
+ * is intentionally surfaced as a distinct error so callers can give operators
8
+ * an actionable message (e.g. "check server health / retry").
9
+ */
2
10
  export async function gql(query, variables = {}, flags = {}) {
3
11
  const token = getToken();
4
12
  const url = getApiUrl();
13
+ const timeoutMs = flags.timeoutMs ?? 20_000;
14
+ const controller = new AbortController();
15
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
5
16
  let res;
6
17
  try {
7
18
  if (flags.debug) {
@@ -9,6 +20,7 @@ export async function gql(query, variables = {}, flags = {}) {
9
20
  }
10
21
  res = await fetch(url, {
11
22
  method: 'POST',
23
+ signal: controller.signal,
12
24
  headers: {
13
25
  'Content-Type': 'application/json',
14
26
  Authorization: `Bearer ${token}`,
@@ -16,9 +28,18 @@ export async function gql(query, variables = {}, flags = {}) {
16
28
  body: JSON.stringify({ query, variables }),
17
29
  });
18
30
  }
19
- catch {
31
+ catch (err) {
32
+ clearTimeout(timer);
33
+ if (err instanceof DOMException && err.name === 'AbortError') {
34
+ throw new Error(`Request timed out after ${timeoutMs / 1000}s. ` +
35
+ 'The server may be overloaded or unreachable. ' +
36
+ 'Check SONAR_API_URL, your network connection, and retry.');
37
+ }
20
38
  throw new Error('Unable to reach server, please try again shortly.');
21
39
  }
40
+ finally {
41
+ clearTimeout(timer);
42
+ }
22
43
  if (!res.ok) {
23
44
  if (flags.debug) {
24
45
  console.error(JSON.stringify(await res.json(), null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1a35e1/sonar-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "X/Twitter social graph CLI for signal filtering and curation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "graphql": "^16.12.0",
23
23
  "graphql-request": "^7.4.0",
24
24
  "ink": "^6",
25
+ "ink-link": "^5.0.0",
25
26
  "ink-table": "^3.1.0",
26
27
  "pastel": "^3.0.0",
27
28
  "react": "^19",
@@ -34,7 +35,6 @@
34
35
  "@types/node": "^22",
35
36
  "@types/react": "^19",
36
37
  "biome": "^0.3.3",
37
- "ink-link": "^5.0.0",
38
38
  "tsx": "^4",
39
39
  "typescript": "^5"
40
40
  },