@1a35e1/sonar-cli 0.1.3 → 0.2.1
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 +2 -2
- package/dist/commands/config/data/backup.js +74 -0
- package/dist/commands/config/data/restore.js +86 -0
- package/dist/commands/config/data/utils.js +39 -0
- package/dist/commands/config/data/verify.js +44 -0
- package/dist/commands/feed.js +15 -1
- package/dist/commands/inbox/index.js +12 -1
- package/dist/commands/index.js +1 -1
- package/dist/commands/ingest/bookmarks.js +28 -4
- package/dist/commands/ingest/tweets.js +28 -4
- package/dist/commands/interests/create.js +17 -4
- package/dist/commands/interests/update.js +18 -7
- package/dist/commands/monitor.js +13 -1
- package/dist/commands/quickstart.js +231 -0
- package/dist/lib/ai.js +77 -35
- package/dist/lib/client.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/feed.js
CHANGED
|
@@ -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 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
|
-
|
|
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 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 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
|
-
|
|
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 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) });
|
package/dist/commands/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box } from 'ink';
|
|
3
3
|
export default function Index() {
|
|
4
|
-
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Sonar CLI" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Commands:" }), _jsx(Text, { children: " feed Scored tweet feed from your network" }), _jsx(Text, { children: " inbox Suggestions matching your interests" }), _jsx(Text, { children: " interests Manage interests" }), _jsx(Text, { children: " \u2514\u2500\u2500 create Create a new interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 update Update an interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 match Match interests to ingested tweets" }), _jsx(Text, { children: " ingest Ingest tweets and bookmarks" }), _jsx(Text, { children: " \u2514\u2500\u2500 tweets Ingest recent tweets from social graph" }), _jsx(Text, { children: " \u2514\u2500\u2500 bookmarks Ingest X bookmarks" }), _jsx(Text, { children: " monitor Job queue monitor and account status" }), _jsx(Text, { children: " config Show or set CLI config" }), _jsx(Text, { children: " account Account info and plan usage" })] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar <command> --help" }), " for command-specific options."] })] }));
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Sonar CLI" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Commands:" }), _jsx(Text, { children: " feed Scored tweet feed from your network" }), _jsx(Text, { children: " inbox Suggestions matching your interests" }), _jsx(Text, { children: " interests Manage interests" }), _jsx(Text, { children: " \u2514\u2500\u2500 create Create a new interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 update Update an interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 match Match interests to ingested tweets" }), _jsx(Text, { children: " ingest Ingest tweets and bookmarks" }), _jsx(Text, { children: " \u2514\u2500\u2500 tweets Ingest recent tweets from social graph" }), _jsx(Text, { children: " \u2514\u2500\u2500 bookmarks Ingest X bookmarks" }), _jsx(Text, { children: " monitor Job queue monitor and account status" }), _jsx(Text, { children: " config Show or set CLI config" }), _jsx(Text, { children: " account Account info and plan usage" }), _jsx(Text, { children: " quickstart First-run setup wizard" })] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar <command> --help" }), " for command-specific options."] })] }));
|
|
5
5
|
}
|
|
@@ -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 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:
|
|
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 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 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:
|
|
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 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 {
|
|
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 ${
|
|
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 {
|
|
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 ${
|
|
136
|
-
:
|
|
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(', ') })] }))] }));
|
package/dist/commands/monitor.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
|
+
import { gql } from '../lib/client.js';
|
|
5
|
+
import { readConfig } from '../lib/config.js';
|
|
6
|
+
import { Spinner } from '../components/Spinner.js';
|
|
7
|
+
// ─── Queries / Mutations ──────────────────────────────────────────────────────
|
|
8
|
+
const BOOTSTRAP_QUERY = `
|
|
9
|
+
query QuickstartBootstrap {
|
|
10
|
+
me {
|
|
11
|
+
xHandle
|
|
12
|
+
}
|
|
13
|
+
projects {
|
|
14
|
+
id: nanoId
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
`;
|
|
18
|
+
const CREATE_MUTATION = `
|
|
19
|
+
mutation CreateOrUpdateInterest(
|
|
20
|
+
$nanoId: String
|
|
21
|
+
$name: String!
|
|
22
|
+
$description: String
|
|
23
|
+
$keywords: [String!]
|
|
24
|
+
$relatedTopics: [String!]
|
|
25
|
+
) {
|
|
26
|
+
createOrUpdateProject(input: {
|
|
27
|
+
nanoId: $nanoId
|
|
28
|
+
name: $name
|
|
29
|
+
description: $description
|
|
30
|
+
keywords: $keywords
|
|
31
|
+
relatedTopics: $relatedTopics
|
|
32
|
+
}) {
|
|
33
|
+
nanoId
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
const INGEST_MUTATION = `
|
|
38
|
+
mutation IndexTweets {
|
|
39
|
+
indexTweets
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
const INBOX_QUERY = `
|
|
43
|
+
query QuickstartInbox($status: SuggestionStatus, $limit: Int) {
|
|
44
|
+
suggestions(status: $status, limit: $limit) {
|
|
45
|
+
suggestionId
|
|
46
|
+
score
|
|
47
|
+
projectsMatched
|
|
48
|
+
status
|
|
49
|
+
tweet {
|
|
50
|
+
xid
|
|
51
|
+
text
|
|
52
|
+
createdAt
|
|
53
|
+
user {
|
|
54
|
+
displayName
|
|
55
|
+
username
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
`;
|
|
61
|
+
// ─── Starter interest suggestions ────────────────────────────────────────────
|
|
62
|
+
/**
|
|
63
|
+
* Returns 3 sensible starter interest drafts. In the future this could use
|
|
64
|
+
* the user's X bio / pinned tweet, but for now these are broadly useful
|
|
65
|
+
* defaults for the typical Sonar user (tech-forward Twitter crowd).
|
|
66
|
+
*/
|
|
67
|
+
function buildStarterSuggestions(_xHandle) {
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
name: 'AI and machine learning',
|
|
71
|
+
description: 'Breakthroughs, papers, tools, and discussion around AI, LLMs, and machine learning.',
|
|
72
|
+
keywords: ['LLM', 'AI agents', 'machine learning', 'GPT', 'fine-tuning', 'inference'],
|
|
73
|
+
relatedTopics: ['artificial intelligence', 'deep learning', 'foundation models'],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'Software engineering and developer tools',
|
|
77
|
+
description: 'New frameworks, libraries, OSS releases, and engineering practices worth tracking.',
|
|
78
|
+
keywords: ['open source', 'TypeScript', 'Rust', 'developer tools', 'CLI', 'API design'],
|
|
79
|
+
relatedTopics: ['software development', 'devex', 'programming'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Tech startups and product launches',
|
|
83
|
+
description: 'Funding rounds, product launches, founder insights, and market moves in tech.',
|
|
84
|
+
keywords: ['startup', 'YC', 'product launch', 'founder', 'seed round', 'SaaS'],
|
|
85
|
+
relatedTopics: ['venture capital', 'entrepreneurship', 'B2B software'],
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
90
|
+
function relativeTime(dateStr) {
|
|
91
|
+
const ts = new Date(dateStr).getTime();
|
|
92
|
+
if (isNaN(ts))
|
|
93
|
+
return '?';
|
|
94
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
95
|
+
const mins = Math.floor(diff / 60000);
|
|
96
|
+
if (mins < 60)
|
|
97
|
+
return `${mins}m`;
|
|
98
|
+
const hours = Math.floor(mins / 60);
|
|
99
|
+
if (hours < 24)
|
|
100
|
+
return `${hours}h`;
|
|
101
|
+
return `${Math.floor(hours / 24)}d`;
|
|
102
|
+
}
|
|
103
|
+
function hasToken() {
|
|
104
|
+
if (process.env.SONAR_API_KEY?.trim())
|
|
105
|
+
return true;
|
|
106
|
+
const config = readConfig();
|
|
107
|
+
return Boolean(config.token?.trim());
|
|
108
|
+
}
|
|
109
|
+
// ─── Sub-renders ──────────────────────────────────────────────────────────────
|
|
110
|
+
function UnauthenticatedView() {
|
|
111
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "\u26A0 Not authenticated" }), _jsxs(Text, { children: ["Sonar needs an API key to get started. Get one at", ' ', _jsx(Text, { color: "cyan", children: "https://sonar.8640p.info" })] }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: "Then run one of:" }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "SONAR_API_KEY=<key> sonar quickstart" }), " (one-off)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "sonar config setup --key <key>" }), " (persist to ~/.sonar/config.json)"] })] })] }));
|
|
112
|
+
}
|
|
113
|
+
function ConfirmView({ me, suggestions, onConfirm, onAbort, }) {
|
|
114
|
+
useInput((input, key) => {
|
|
115
|
+
if (key.return || input === 'y' || input === 'Y') {
|
|
116
|
+
onConfirm();
|
|
117
|
+
}
|
|
118
|
+
else if (input === 'n' || input === 'N' || key.escape) {
|
|
119
|
+
onAbort();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Welcome to Sonar," }), _jsxs(Text, { bold: true, children: ["@", me.xHandle, "!"] })] }), _jsx(Text, { children: "You have no interests set up yet. Here are 3 starter suggestions to get your inbox going:" }), suggestions.map((s, i) => (_jsxs(Box, { flexDirection: "column", gap: 0, paddingLeft: 2, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: "cyan", children: [i + 1, "."] }), _jsx(Text, { bold: true, children: s.name })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: s.description }) }), _jsxs(Box, { gap: 1, paddingLeft: 4, children: [_jsx(Text, { dimColor: true, children: "keywords:" }), _jsx(Text, { dimColor: true, children: s.keywords.slice(0, 4).join(', ') })] })] }, s.name))), _jsxs(Box, { marginTop: 1, gap: 1, children: [_jsx(Text, { dimColor: true, children: "Create these interests and kick off indexing?" }), _jsx(Text, { bold: true, color: "cyan", children: "[Y/n]" })] }), _jsxs(Text, { dimColor: true, children: ["tip: customise later with", ' ', _jsx(Text, { color: "cyan", children: "sonar interests create --from-prompt \"...\"" })] })] }));
|
|
123
|
+
}
|
|
124
|
+
function CreatingView({ suggestions, progress }) {
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Setting up interests" }), _jsxs(Text, { dimColor: true, children: ["(", progress, "/", suggestions.length, ")"] })] }), suggestions.map((s, i) => (_jsxs(Box, { gap: 1, children: [i < progress ? (_jsx(Text, { color: "green", children: "\u2713" })) : i === progress ? (_jsx(Spinner, { label: "" })) : (_jsx(Text, { dimColor: true, children: "\u00B7" })), _jsx(Text, { dimColor: i > progress, color: i < progress ? 'green' : undefined, children: s.name })] }, s.name)))] }));
|
|
126
|
+
}
|
|
127
|
+
function InboxView({ items, created }) {
|
|
128
|
+
if (items.length === 0) {
|
|
129
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [created ? (_jsx(Text, { color: "green", children: "\u2713 Interests created and indexing triggered!" })) : (_jsx(Text, { color: "green", children: "\u2713 Your interests are set up \u2014 indexing is in progress." })), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { children: "Your inbox is empty right now \u2014 indexing takes a few minutes." }), _jsxs(Text, { dimColor: true, children: ["Check back shortly with: ", _jsx(Text, { color: "cyan", children: "sonar inbox" })] })] }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Text, { dimColor: true, children: ["Monitor indexing progress: ", _jsx(Text, { color: "cyan", children: "sonar monitor" })] }), _jsxs(Text, { dimColor: true, children: ["Browse your full inbox: ", _jsx(Text, { color: "cyan", children: "sonar inbox" })] }), _jsxs(Text, { dimColor: true, children: ["Edit interests: ", _jsx(Text, { color: "cyan", children: "sonar interests" })] })] })] }));
|
|
130
|
+
}
|
|
131
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713 You're all set! Here's your inbox:" }), items.slice(0, 10).map((s) => {
|
|
132
|
+
const handle = s.tweet.user.username ?? s.tweet.user.displayName;
|
|
133
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "cyan", children: relativeTime(s.tweet.createdAt) }), _jsx(Text, { color: "green", children: s.score.toFixed(2) }), _jsxs(Text, { dimColor: true, children: ["@", handle] })] }), _jsx(Box, { paddingLeft: 2, width: 80, children: _jsx(Text, { wrap: "wrap", dimColor: true, children: s.tweet.text.replace(/\n/g, ' ').slice(0, 120) }) })] }, s.suggestionId));
|
|
134
|
+
}), items.length > 10 && (_jsxs(Text, { dimColor: true, children: ["\u2026 and ", items.length - 10, " more. Run ", _jsx(Text, { color: "cyan", children: "sonar inbox" }), " to see all."] })), _jsxs(Text, { dimColor: true, children: ["Interactive mode: ", _jsx(Text, { color: "cyan", children: "sonar inbox --interactive" }), ' · ', "Full inbox: ", _jsx(Text, { color: "cyan", children: "sonar inbox" })] })] }));
|
|
135
|
+
}
|
|
136
|
+
// ─── Main component ───────────────────────────────────────────────────────────
|
|
137
|
+
export default function Quickstart() {
|
|
138
|
+
const { exit } = useApp();
|
|
139
|
+
const [phase, setPhase] = useState({ type: 'loading' });
|
|
140
|
+
const abortedRef = useRef(false);
|
|
141
|
+
const confirmedRef = useRef(false);
|
|
142
|
+
// ── Bootstrap: check auth + fetch me + projects ──────────────────────────
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!hasToken()) {
|
|
145
|
+
setPhase({ type: 'unauthenticated' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
async function bootstrap() {
|
|
149
|
+
try {
|
|
150
|
+
const result = await gql(BOOTSTRAP_QUERY);
|
|
151
|
+
if (!result.me) {
|
|
152
|
+
setPhase({ type: 'unauthenticated' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// If interests already exist, jump straight to inbox
|
|
156
|
+
if (result.projects.length > 0) {
|
|
157
|
+
const inbox = await gql(INBOX_QUERY, {
|
|
158
|
+
status: 'INBOX',
|
|
159
|
+
limit: 20,
|
|
160
|
+
});
|
|
161
|
+
setPhase({ type: 'inbox', items: inbox.suggestions, created: false });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// No interests — propose starters
|
|
165
|
+
const suggestions = buildStarterSuggestions(result.me.xHandle);
|
|
166
|
+
setPhase({ type: 'confirm', me: result.me, suggestions });
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
setPhase({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
bootstrap();
|
|
173
|
+
}, []);
|
|
174
|
+
// ── Create interests + ingest (triggered from confirm handler) ────────────
|
|
175
|
+
const handleConfirm = async (suggestions) => {
|
|
176
|
+
if (confirmedRef.current)
|
|
177
|
+
return;
|
|
178
|
+
confirmedRef.current = true;
|
|
179
|
+
setPhase({ type: 'creating', suggestions, progress: 0 });
|
|
180
|
+
try {
|
|
181
|
+
// Create each interest sequentially so progress counter is accurate
|
|
182
|
+
for (let i = 0; i < suggestions.length; i++) {
|
|
183
|
+
if (abortedRef.current)
|
|
184
|
+
return;
|
|
185
|
+
const s = suggestions[i];
|
|
186
|
+
await gql(CREATE_MUTATION, {
|
|
187
|
+
nanoId: null,
|
|
188
|
+
name: s.name,
|
|
189
|
+
description: s.description,
|
|
190
|
+
keywords: s.keywords,
|
|
191
|
+
relatedTopics: s.relatedTopics,
|
|
192
|
+
});
|
|
193
|
+
setPhase({ type: 'creating', suggestions, progress: i + 1 });
|
|
194
|
+
}
|
|
195
|
+
// Trigger ingest
|
|
196
|
+
setPhase({ type: 'ingesting' });
|
|
197
|
+
await gql(INGEST_MUTATION);
|
|
198
|
+
// Fetch initial inbox (may be empty — that's fine)
|
|
199
|
+
const inbox = await gql(INBOX_QUERY, {
|
|
200
|
+
status: 'INBOX',
|
|
201
|
+
limit: 20,
|
|
202
|
+
});
|
|
203
|
+
setPhase({ type: 'inbox', items: inbox.suggestions, created: true });
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
setPhase({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const handleAbort = () => {
|
|
210
|
+
abortedRef.current = true;
|
|
211
|
+
process.stdout.write('\nAborted. Run sonar quickstart again whenever you\'re ready.\n');
|
|
212
|
+
exit();
|
|
213
|
+
};
|
|
214
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
215
|
+
switch (phase.type) {
|
|
216
|
+
case 'loading':
|
|
217
|
+
return _jsx(Spinner, { label: "Loading your Sonar profile..." });
|
|
218
|
+
case 'unauthenticated':
|
|
219
|
+
return _jsx(UnauthenticatedView, {});
|
|
220
|
+
case 'error':
|
|
221
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "red", children: ["Error: ", phase.message] }), _jsxs(Text, { dimColor: true, children: ["Check your connection and API key, then retry: ", _jsx(Text, { color: "cyan", children: "sonar quickstart" })] })] }));
|
|
222
|
+
case 'confirm':
|
|
223
|
+
return (_jsx(ConfirmView, { me: phase.me, suggestions: phase.suggestions, onConfirm: () => handleConfirm(phase.suggestions), onAbort: handleAbort }));
|
|
224
|
+
case 'creating':
|
|
225
|
+
return _jsx(CreatingView, { suggestions: phase.suggestions, progress: phase.progress });
|
|
226
|
+
case 'ingesting':
|
|
227
|
+
return _jsx(Spinner, { label: "Triggering tweet indexing..." });
|
|
228
|
+
case 'inbox':
|
|
229
|
+
return _jsx(InboxView, { items: phase.items, created: phase.created });
|
|
230
|
+
}
|
|
231
|
+
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/dist/lib/client.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
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) {
|
|
8
19
|
console.error(url, query, variables);
|
|
9
20
|
}
|
|
21
|
+
console.log('url', url);
|
|
10
22
|
res = await fetch(url, {
|
|
11
23
|
method: 'POST',
|
|
24
|
+
signal: controller.signal,
|
|
12
25
|
headers: {
|
|
13
26
|
'Content-Type': 'application/json',
|
|
14
27
|
Authorization: `Bearer ${token}`,
|
|
@@ -16,9 +29,18 @@ export async function gql(query, variables = {}, flags = {}) {
|
|
|
16
29
|
body: JSON.stringify({ query, variables }),
|
|
17
30
|
});
|
|
18
31
|
}
|
|
19
|
-
catch {
|
|
32
|
+
catch (err) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
35
|
+
throw new Error(`Request timed out after ${timeoutMs / 1000}s. ` +
|
|
36
|
+
'The server may be overloaded or unreachable. ' +
|
|
37
|
+
'Check SONAR_API_URL, your network connection, and retry.');
|
|
38
|
+
}
|
|
20
39
|
throw new Error('Unable to reach server, please try again shortly.');
|
|
21
40
|
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
22
44
|
if (!res.ok) {
|
|
23
45
|
if (flags.debug) {
|
|
24
46
|
console.error(JSON.stringify(await res.json(), null, 2));
|