@1a35e1/sonar-cli 0.3.5 → 0.4.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
@@ -19,11 +19,7 @@ pnpm add -g @1a35e1/sonar-cli@latest
19
19
  Register your API key.
20
20
 
21
21
  ```sh
22
- # Ensure "SONAR_API_KEY" available in your env
23
- export SONAR_API_KEY=snr_xxxxx
24
-
25
- # or, manually register
26
- sonar config setup key=<YOUR_API_KEY>
22
+ sonar account add snr_xxxxx
27
23
  ```
28
24
 
29
25
  View your account status:
@@ -160,8 +156,7 @@ What this means in practice:
160
156
  ```bash
161
157
  pnpm add -g @1a35e1/sonar-cli@latest
162
158
 
163
- export SONAR_API_KEY="your_api_key_here"
164
- sonar config setup key=<YOUR_API_KEY>
159
+ sonar account add <YOUR_API_KEY>
165
160
  ```
166
161
 
167
162
  Verify it works:
@@ -269,7 +264,6 @@ sonar sync bookmarks # sync bookmarks to local SQLite
269
264
 
270
265
  | Variable | Required | Purpose |
271
266
  | ------------------- | -------------------- | ------------------------------------------------------------------- |
272
- | `SONAR_API_KEY` | Yes | Auth token from [sonar.8640p.info](https://sonar.8640p.info/) |
273
267
  | `SONAR_API_URL` | No | GraphQL endpoint (default: production API) |
274
268
  | `SONAR_MAX_RETRIES` | No | Max retry attempts on transient failures (default: 3, 0 to disable) |
275
269
  | `OPENAI_API_KEY` | For `topics suggest` | Required when using OpenAI vendor for AI suggestions |
@@ -301,8 +295,8 @@ Locally, it skips when offline; in CI (`CI=true`) it is enforced.
301
295
 
302
296
  ## Troubleshooting
303
297
 
304
- **`No token found. Set SONAR_API_KEY or run: sonar config setup`**
305
- Set `SONAR_API_KEY` in your environment or run `sonar config setup key=<YOUR_KEY>`.
298
+ **`No token found. Run: sonar account add <name> <key>`**
299
+ Add an account with `sonar account add <YOUR_KEY>`. Get a key at [sonar.8640p.info](https://sonar.8640p.info/).
306
300
 
307
301
  **`Unable to reach server, please try again shortly.`**
308
302
  Check your network connection and API availability. The CLI automatically retries transient failures (network errors, 5xx) up to 3 times with exponential backoff. Use `--debug` to see retry attempts. Set `SONAR_MAX_RETRIES=0` to disable retries.
package/dist/cli.js CHANGED
@@ -1,4 +1,17 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
2
5
  import Pastel from 'pastel';
3
- const app = new Pastel({ importMeta: import.meta });
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
8
+ const HEADER = `
9
+ S O N A R
10
+ ────────────────────────
11
+ ${pkg.version}
12
+ `;
13
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
14
+ process.stdout.write(HEADER);
15
+ }
16
+ const app = new Pastel({ importMeta: import.meta, name: 'sonar' });
4
17
  await app.run();
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { readAccounts, writeAccounts, migrateToAccounts } from '../../lib/config.js';
6
+ const ADJECTIVES = [
7
+ 'bouncy', 'cosmic', 'dizzy', 'fuzzy', 'gentle', 'happy', 'jazzy',
8
+ 'lucky', 'mellow', 'nimble', 'plucky', 'quiet', 'rusty', 'snappy',
9
+ 'tiny', 'vivid', 'witty', 'zesty', 'bright', 'clever',
10
+ ];
11
+ const ANIMALS = [
12
+ 'rabbit', 'falcon', 'panda', 'otter', 'fox', 'wolf', 'eagle',
13
+ 'dolphin', 'tiger', 'koala', 'lynx', 'owl', 'raven', 'seal',
14
+ 'hawk', 'badger', 'crane', 'finch', 'heron', 'wren',
15
+ ];
16
+ function randomName() {
17
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
18
+ const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
19
+ return `${adj}-${animal}`;
20
+ }
21
+ export const args = zod.tuple([
22
+ zod.string().describe('API key (snr_...)'),
23
+ ]);
24
+ export const options = zod.object({
25
+ alias: zod.string().optional().describe('Account alias (default: random)'),
26
+ 'api-url': zod.string().optional().describe('Custom API URL'),
27
+ });
28
+ export default function AccountAdd({ args: [key], options: flags }) {
29
+ useEffect(() => {
30
+ migrateToAccounts();
31
+ if (!key.startsWith('snr_')) {
32
+ process.stderr.write('Invalid API key — must start with "snr_"\n');
33
+ process.exit(1);
34
+ }
35
+ const data = readAccounts();
36
+ let name = flags.alias ?? randomName();
37
+ // Avoid collisions with existing names
38
+ while (data.accounts[name]) {
39
+ name = randomName();
40
+ }
41
+ data.accounts[name] = {
42
+ token: key,
43
+ apiUrl: flags['api-url'] ?? 'https://api.sonar.8640p.info/graphql',
44
+ };
45
+ // If this is the first account, make it active
46
+ if (!data.active || !data.accounts[data.active]) {
47
+ data.active = name;
48
+ }
49
+ writeAccounts(data);
50
+ const isActive = data.active === name ? ' (active)' : '';
51
+ process.stdout.write(`Account "${name}" added${isActive}\n`);
52
+ if (!flags.alias) {
53
+ process.stdout.write(`tip rename with: sonar account rename ${name} <your-name>\n`);
54
+ }
55
+ process.exit(0);
56
+ }, []);
57
+ return _jsx(Text, { dimColor: true, children: "Adding account..." });
58
+ }
@@ -0,0 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import zod from 'zod';
3
+ import { Box, Text } from 'ink';
4
+ import { readAccounts, migrateToAccounts } from '../../lib/config.js';
5
+ export const options = zod.object({
6
+ json: zod.boolean().default(false).describe('Raw JSON output'),
7
+ });
8
+ function maskToken(token) {
9
+ if (token.length <= 8)
10
+ return '***';
11
+ return token.slice(0, 4) + '...' + token.slice(-4);
12
+ }
13
+ export default function AccountList({ options: flags }) {
14
+ migrateToAccounts();
15
+ const { active, accounts } = readAccounts();
16
+ const names = Object.keys(accounts);
17
+ if (flags.json) {
18
+ process.stdout.write(JSON.stringify({ active, accounts: names }, null, 2) + '\n');
19
+ process.exit(0);
20
+ return _jsx(_Fragment, {});
21
+ }
22
+ if (names.length === 0) {
23
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { children: "No accounts configured." }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: " sonar account add snr_xxxxx" }), _jsx(Text, { dimColor: true, children: " sonar account add snr_yyyyy --alias work" })] })] }));
24
+ }
25
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Accounts" }), _jsxs(Text, { dimColor: true, children: [" (", names.length, ")"] })] }), names.map(name => {
26
+ const isActive = name === active;
27
+ const entry = accounts[name];
28
+ return (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: isActive ? 'green' : undefined, children: [isActive ? '* ' : ' ', name] }), _jsx(Text, { dimColor: true, children: maskToken(entry.token) }), entry.apiUrl !== 'https://api.sonar.8640p.info/graphql' && (_jsx(Text, { dimColor: true, children: entry.apiUrl }))] }, name));
29
+ }), _jsx(Text, { dimColor: true, children: "switch: sonar account switch <name>" })] }));
30
+ }
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { readAccounts, writeAccounts } from '../../lib/config.js';
6
+ export const args = zod.tuple([
7
+ zod.string().describe('Account name to remove'),
8
+ ]);
9
+ export const options = zod.object({
10
+ force: zod.boolean().default(false).describe('Remove even if active'),
11
+ });
12
+ export default function AccountRemove({ args: [name], options: flags }) {
13
+ useEffect(() => {
14
+ const data = readAccounts();
15
+ if (!data.accounts[name]) {
16
+ process.stderr.write(`Account "${name}" not found.\n`);
17
+ process.exit(1);
18
+ }
19
+ if (data.active === name && !flags.force) {
20
+ process.stderr.write(`"${name}" is the active account. Switch first, or use --force.\n`);
21
+ process.exit(1);
22
+ }
23
+ delete data.accounts[name];
24
+ // If we removed the active account, pick the first remaining one
25
+ if (data.active === name) {
26
+ const remaining = Object.keys(data.accounts);
27
+ data.active = remaining.length > 0 ? remaining[0] : '';
28
+ }
29
+ writeAccounts(data);
30
+ process.stdout.write(`Account "${name}" removed\n`);
31
+ process.exit(0);
32
+ }, []);
33
+ return _jsx(Text, { dimColor: true, children: "Removing account..." });
34
+ }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { readAccounts, writeAccounts } from '../../lib/config.js';
6
+ export const args = zod.tuple([
7
+ zod.string().describe('Current account name'),
8
+ zod.string().describe('New account name'),
9
+ ]);
10
+ export default function AccountRename({ args: [oldName, newName] }) {
11
+ useEffect(() => {
12
+ const data = readAccounts();
13
+ if (!data.accounts[oldName]) {
14
+ const names = Object.keys(data.accounts);
15
+ process.stderr.write(`Account "${oldName}" not found.`);
16
+ if (names.length > 0)
17
+ process.stderr.write(` Available: ${names.join(', ')}`);
18
+ process.stderr.write('\n');
19
+ process.exit(1);
20
+ }
21
+ if (data.accounts[newName]) {
22
+ process.stderr.write(`Account "${newName}" already exists.\n`);
23
+ process.exit(1);
24
+ }
25
+ data.accounts[newName] = data.accounts[oldName];
26
+ delete data.accounts[oldName];
27
+ if (data.active === oldName) {
28
+ data.active = newName;
29
+ }
30
+ writeAccounts(data);
31
+ process.stdout.write(`Renamed "${oldName}" → "${newName}"\n`);
32
+ process.exit(0);
33
+ }, []);
34
+ return _jsx(Text, { dimColor: true, children: "Renaming account..." });
35
+ }
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import zod from 'zod';
4
+ import { Text } from 'ink';
5
+ import { readAccounts, writeAccounts } from '../../lib/config.js';
6
+ export const args = zod.tuple([
7
+ zod.string().describe('Account name to switch to'),
8
+ ]);
9
+ export default function AccountSwitch({ args: [name] }) {
10
+ useEffect(() => {
11
+ const data = readAccounts();
12
+ if (!data.accounts[name]) {
13
+ const names = Object.keys(data.accounts);
14
+ process.stderr.write(`Account "${name}" not found.`);
15
+ if (names.length > 0) {
16
+ process.stderr.write(` Available: ${names.join(', ')}`);
17
+ }
18
+ process.stderr.write('\n');
19
+ process.exit(1);
20
+ }
21
+ data.active = name;
22
+ writeAccounts(data);
23
+ process.stdout.write(`Switched to "${name}"\n`);
24
+ process.exit(0);
25
+ }, []);
26
+ return _jsx(Text, { dimColor: true, children: "Switching account..." });
27
+ }
@@ -1,15 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
- const maskSensitive = (value) => {
5
- return value.replace(/[^a-zA-Z0-9]/g, '*').slice(0, 4) + '***' + value.slice(-4);
6
- };
7
4
  export default function Env() {
8
5
  useEffect(() => {
9
- process.stdout.write(`SONAR_API_KEY=${maskSensitive(process.env.SONAR_API_KEY ?? '')}\n`);
10
- process.stdout.write(`SONAR_AI_VENDOR=${process.env.SONAR_AI_VENDOR}\n`);
11
- process.stdout.write(`SONAR_FEED_RENDER=${process.env.SONAR_FEED_RENDER}\n`);
12
- process.stdout.write(`SONAR_FEED_WIDTH=${process.env.SONAR_FEED_WIDTH}\n`);
6
+ process.stdout.write(`SONAR_API_URL=${process.env.SONAR_API_URL ?? ''}\n`);
7
+ process.stdout.write(`SONAR_AI_VENDOR=${process.env.SONAR_AI_VENDOR ?? ''}\n`);
8
+ process.stdout.write(`SONAR_FEED_RENDER=${process.env.SONAR_FEED_RENDER ?? ''}\n`);
9
+ process.stdout.write(`SONAR_FEED_WIDTH=${process.env.SONAR_FEED_WIDTH ?? ''}\n`);
10
+ process.stdout.write(`SONAR_MAX_RETRIES=${process.env.SONAR_MAX_RETRIES ?? ''}\n`);
13
11
  }, []);
14
12
  return _jsx(Text, { dimColor: true, children: "Environment variables:" });
15
13
  }
@@ -12,10 +12,10 @@ export default function Setup({ options: flags }) {
12
12
  process.stderr.write('Workspace already initialised at ~/.sonar/config.json\n');
13
13
  process.exit(1);
14
14
  }
15
- const apiKey = flags.key || process.env.SONAR_API_KEY;
15
+ const apiKey = flags.key;
16
16
  const apiUrl = process.env.SONAR_API_URL;
17
17
  if (!apiKey) {
18
- process.stderr.write('SONAR_API_KEY is not set. Generate a key at https://sonar.8640p.info\n');
18
+ process.stderr.write('API key required. Run: sonar config setup --key=<YOUR_KEY>\n');
19
19
  process.exit(1);
20
20
  }
21
21
  writeConfig({
@@ -0,0 +1,57 @@
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 { DB_PATH } from '../../lib/db.js';
8
+ import { integrityCheck, copyDbWithSidecars } from '../../lib/data-utils.js';
9
+ export const options = zod.object({
10
+ out: zod.string().optional().describe('Backup output path (default: ~/.sonar/data-backup-<timestamp>.db)'),
11
+ json: zod.boolean().default(false).describe('Raw JSON output'),
12
+ });
13
+ function ts() {
14
+ const d = new Date();
15
+ const p = (n) => String(n).padStart(2, '0');
16
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
17
+ }
18
+ export default function DataBackup({ options: flags }) {
19
+ const [error, setError] = useState(null);
20
+ useEffect(() => {
21
+ try {
22
+ if (!existsSync(DB_PATH))
23
+ throw new Error(`source database not found: ${DB_PATH}`);
24
+ const trimmedOut = flags.out?.trim();
25
+ const out = trimmedOut && trimmedOut.length > 0
26
+ ? trimmedOut
27
+ : join(dirname(DB_PATH), `${basename(DB_PATH, '.db')}-backup-${ts()}.db`);
28
+ mkdirSync(dirname(out), { recursive: true });
29
+ copyDbWithSidecars(DB_PATH, out);
30
+ const check = integrityCheck(out);
31
+ if (check !== 'ok')
32
+ throw new Error(`backup integrity check failed: ${check}`);
33
+ const result = { ok: true, source: DB_PATH, backup: out };
34
+ if (flags.json) {
35
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
36
+ }
37
+ else {
38
+ process.stdout.write(`Backup complete: ${out}\n`);
39
+ }
40
+ process.exit(0);
41
+ }
42
+ catch (e) {
43
+ setError(e instanceof Error ? e.message : String(e));
44
+ }
45
+ }, []);
46
+ useEffect(() => {
47
+ if (!error)
48
+ return;
49
+ if (flags.json) {
50
+ process.stderr.write(`${error}\n`);
51
+ process.exit(1);
52
+ }
53
+ }, [error, flags.json]);
54
+ if (error)
55
+ return flags.json ? _jsx(_Fragment, {}) : _jsxs(Text, { color: "red", children: ["Error: ", error] });
56
+ return flags.json ? _jsx(_Fragment, {}) : _jsx(Text, { dimColor: true, children: "Creating backup..." });
57
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
- import { DB_PATH } from '../../../lib/db.js';
4
+ import { DB_PATH } from '../../lib/db.js';
5
5
  export default function DataPath() {
6
6
  useEffect(() => {
7
7
  process.stdout.write(`${DB_PATH}\n`);
@@ -2,10 +2,10 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Box, Text } from 'ink';
4
4
  import { unlinkSync, existsSync } from 'node:fs';
5
- import { gql } from '../../../lib/client.js';
6
- import { Spinner } from '../../../components/Spinner.js';
7
- import { DB_PATH, openDb, upsertTweet, upsertFeedItem, upsertSuggestion, upsertInterest, getSyncState, setSyncState, } from '../../../lib/db.js';
8
- import { FEED_QUERY, SUGGESTIONS_QUERY, INTERESTS_QUERY } from '../../../lib/data-queries.js';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ import { DB_PATH, openDb, upsertTweet, upsertFeedItem, upsertSuggestion, upsertTopic, getSyncState, setSyncState, } from '../../lib/db.js';
8
+ import { FEED_QUERY, SUGGESTIONS_QUERY, INTERESTS_QUERY } from '../../lib/data-queries.js';
9
9
  export default function DataSync() {
10
10
  const [result, setResult] = useState(null);
11
11
  const [error, setError] = useState(null);
@@ -32,12 +32,12 @@ export default function DataSync() {
32
32
  upsertTweet(freshDb, s.tweet);
33
33
  upsertSuggestion(freshDb, { suggestionId: s.suggestionId, tweetId: s.tweet.id, score: s.score, status: s.status, relevance: null, projectsMatched: s.projectsMatched });
34
34
  }
35
- for (const i of interestsResult.topics) {
36
- upsertInterest(freshDb, i);
35
+ for (const t of interestsResult.topics) {
36
+ upsertTopic(freshDb, t);
37
37
  }
38
38
  setSyncState(freshDb, 'last_synced_at', new Date().toISOString());
39
39
  freshDb.close();
40
- setResult({ feedCount: feedResult.feed.length, suggestionsCount: suggestionsResult.suggestions.length, interestsCount: interestsResult.topics.length });
40
+ setResult({ feedCount: feedResult.feed.length, suggestionsCount: suggestionsResult.suggestions.length, topicsCount: interestsResult.topics.length });
41
41
  return;
42
42
  }
43
43
  const hoursSinceSync = Math.min(Math.ceil((Date.now() - new Date(lastSyncedAt).getTime()) / 3600000), 168);
@@ -45,8 +45,8 @@ export default function DataSync() {
45
45
  gql(FEED_QUERY, { hours: hoursSinceSync, days: null, limit: 500 }),
46
46
  gql(SUGGESTIONS_QUERY, { status: null, limit: 500 }),
47
47
  ]);
48
- const prevFeedCount = db.prepare('SELECT COUNT(*) as n FROM feed_items').get().n;
49
- const prevSuggestionsCount = db.prepare('SELECT COUNT(*) as n FROM suggestions').get().n;
48
+ const prevFeedCount = db.get('SELECT COUNT(*) as n FROM feed_items').n;
49
+ const prevSuggestionsCount = db.get('SELECT COUNT(*) as n FROM suggestions').n;
50
50
  for (const item of feedResult.feed) {
51
51
  upsertTweet(db, item.tweet);
52
52
  upsertFeedItem(db, { tweetId: item.tweet.id, score: item.score, matchedKeywords: item.matchedKeywords });
@@ -56,13 +56,13 @@ export default function DataSync() {
56
56
  upsertSuggestion(db, { suggestionId: s.suggestionId, tweetId: s.tweet.id, score: s.score, status: s.status, relevance: null, projectsMatched: s.projectsMatched });
57
57
  }
58
58
  setSyncState(db, 'last_synced_at', new Date().toISOString());
59
- const newFeedCount = db.prepare('SELECT COUNT(*) as n FROM feed_items').get().n;
60
- const newSuggestionsCount = db.prepare('SELECT COUNT(*) as n FROM suggestions').get().n;
59
+ const newFeedCount = db.get('SELECT COUNT(*) as n FROM feed_items').n;
60
+ const newSuggestionsCount = db.get('SELECT COUNT(*) as n FROM suggestions').n;
61
61
  db.close();
62
62
  setResult({
63
63
  feedCount: newFeedCount,
64
64
  suggestionsCount: newSuggestionsCount,
65
- interestsCount: 0,
65
+ topicsCount: 0,
66
66
  isSync: true,
67
67
  deltaFeed: newFeedCount - prevFeedCount,
68
68
  deltaSuggestions: newSuggestionsCount - prevSuggestionsCount,
@@ -81,5 +81,5 @@ export default function DataSync() {
81
81
  if (result.isSync) {
82
82
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Sync complete" }), _jsxs(Text, { dimColor: true, children: [" ", DB_PATH] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "feed" }), _jsxs(Text, { dimColor: true, children: [" +", result.deltaFeed ?? 0, " (", result.feedCount, " total) "] }), _jsx(Text, { color: "green", children: "suggestions" }), _jsxs(Text, { dimColor: true, children: [" +", result.deltaSuggestions ?? 0, " (", result.suggestionsCount, " total)"] })] })] }));
83
83
  }
84
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Download complete" }), _jsxs(Text, { dimColor: true, children: [" ", DB_PATH] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: result.feedCount }), _jsx(Text, { dimColor: true, children: " feed items " }), _jsx(Text, { color: "cyan", children: result.suggestionsCount }), _jsx(Text, { dimColor: true, children: " suggestions " }), _jsx(Text, { color: "cyan", children: result.interestsCount }), _jsx(Text, { dimColor: true, children: " interests" })] })] }));
84
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Download complete" }), _jsxs(Text, { dimColor: true, children: [" ", DB_PATH] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: result.feedCount }), _jsx(Text, { dimColor: true, children: " feed items " }), _jsx(Text, { color: "cyan", children: result.suggestionsCount }), _jsx(Text, { dimColor: true, children: " suggestions " }), _jsx(Text, { color: "cyan", children: result.topicsCount }), _jsx(Text, { dimColor: true, children: " topics" })] })] }));
85
85
  }
@@ -4,8 +4,8 @@ import zod from 'zod';
4
4
  import { Text } from 'ink';
5
5
  import { existsSync, mkdirSync, rmSync } from 'node:fs';
6
6
  import { dirname, resolve } from 'node:path';
7
- import { DB_PATH } from '../../../lib/db.js';
8
- import { integrityCheck, copyDbWithSidecars } from './utils.js';
7
+ import { DB_PATH } from '../../lib/db.js';
8
+ import { integrityCheck, copyDbWithSidecars } from '../../lib/data-utils.js';
9
9
  export const options = zod.object({
10
10
  from: zod.string().describe('Backup database path to restore from'),
11
11
  to: zod.string().optional().describe('Target database path (default: local sonar DB path)'),
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { DB_PATH } from '../../../lib/db.js';
5
+ import { DB_PATH } from '../../lib/db.js';
6
6
  export default function DataSql() {
7
7
  useEffect(() => {
8
8
  const { status } = spawnSync('sqlite3', [DB_PATH], { stdio: 'inherit' });
@@ -3,8 +3,8 @@ import { useEffect, useState } from 'react';
3
3
  import zod from 'zod';
4
4
  import { Text } from 'ink';
5
5
  import { existsSync } from 'node:fs';
6
- import { DB_PATH } from '../../../lib/db.js';
7
- import { integrityCheck } from './utils.js';
6
+ import { DB_PATH } from '../../lib/db.js';
7
+ import { integrityCheck } from '../../lib/data-utils.js';
8
8
  export const options = zod.object({
9
9
  path: zod.string().optional().describe('Database path (default: local sonar DB path)'),
10
10
  json: zod.boolean().default(false).describe('Raw JSON output'),
@@ -1,19 +1,73 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
3
4
  import { Box, Text, useApp } from 'ink';
4
5
  import { gql } from '../lib/client.js';
6
+ import { getToken, getApiUrl } from '../lib/config.js';
5
7
  import { Spinner } from '../components/Spinner.js';
6
- export default function Refresh() {
8
+ export const options = zod.object({
9
+ bookmarks: zod.boolean().default(false).describe('Sync bookmarks from X'),
10
+ likes: zod.boolean().default(false).describe('Sync likes from X'),
11
+ graph: zod.boolean().default(false).describe('Rebuild social graph'),
12
+ tweets: zod.boolean().default(false).describe('Index tweets across network'),
13
+ suggestions: zod.boolean().default(false).describe('Regenerate suggestions'),
14
+ });
15
+ function sleep(ms) {
16
+ return new Promise(resolve => setTimeout(resolve, ms));
17
+ }
18
+ const REFRESH_MUTATION = `
19
+ mutation Refresh($days: Int!, $steps: [String!]) {
20
+ refresh(days: $days, steps: $steps)
21
+ }
22
+ `;
23
+ export default function Refresh({ options: flags }) {
7
24
  const { exit } = useApp();
8
25
  const [status, setStatus] = useState('pending');
9
26
  const [error, setError] = useState(null);
10
27
  const [batchId, setBatchId] = useState(null);
28
+ // Build steps array from flags — null means run all
29
+ const selectedSteps = [];
30
+ if (flags.bookmarks)
31
+ selectedSteps.push('bookmarks');
32
+ if (flags.likes)
33
+ selectedSteps.push('likes');
34
+ if (flags.graph)
35
+ selectedSteps.push('graph');
36
+ if (flags.tweets)
37
+ selectedSteps.push('tweets');
38
+ if (flags.suggestions)
39
+ selectedSteps.push('suggestions');
40
+ const steps = selectedSteps.length > 0 ? selectedSteps : null;
11
41
  useEffect(() => {
12
42
  async function run() {
13
43
  setStatus('running');
14
44
  try {
15
- const result = await gql('mutation Refresh { refresh(days: 1) }');
45
+ const result = await gql(REFRESH_MUTATION, {
46
+ days: 1,
47
+ steps,
48
+ });
16
49
  setBatchId(result.refresh);
50
+ // Brief poll to catch instant pipeline failures (e.g. expired X auth)
51
+ await sleep(3000);
52
+ try {
53
+ const token = getToken();
54
+ const baseUrl = getApiUrl().replace(/\/graphql$/, '');
55
+ const res = await fetch(`${baseUrl}/indexing/status`, {
56
+ headers: { Authorization: `Bearer ${token}` },
57
+ });
58
+ if (res.ok) {
59
+ const data = await res.json();
60
+ if (data.pipeline?.status === 'failed') {
61
+ const pipelineError = data.pipeline?.error ?? '';
62
+ setError(pipelineError);
63
+ setStatus('auth-failed');
64
+ return;
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ // Poll failed — not critical, proceed normally
70
+ }
17
71
  setStatus('ok');
18
72
  }
19
73
  catch (err) {
@@ -24,11 +78,15 @@ export default function Refresh() {
24
78
  run();
25
79
  }, []);
26
80
  useEffect(() => {
27
- if (status === 'ok' || status === 'failed')
81
+ if (status === 'ok' || status === 'failed' || status === 'auth-failed')
28
82
  exit();
29
83
  }, [status]);
84
+ const label = steps ? steps.join(', ') : 'full pipeline';
30
85
  if (status === 'running') {
31
- return _jsx(Spinner, { label: "Queuing refresh pipeline..." });
86
+ return _jsx(Spinner, { label: `Queuing refresh (${label})...` });
87
+ }
88
+ if (status === 'auth-failed') {
89
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "red", children: ["Pipeline failed", error ? `: ${error}` : ''] }), _jsxs(Text, { dimColor: true, children: ["Re-connect your X account at ", _jsx(Text, { color: "cyan", children: "https://sonar.8640p.info/account" })] }), _jsxs(Text, { dimColor: true, children: ["Then run ", _jsx(Text, { color: "cyan", children: "sonar refresh" }), " to retry."] })] }));
32
90
  }
33
91
  if (status === 'failed') {
34
92
  const isAuthError = error?.includes('Re-authorize') || error?.includes('not connected');
@@ -37,5 +95,5 @@ export default function Refresh() {
37
95
  }
38
96
  return _jsxs(Text, { color: "red", children: ["Error: ", error] });
39
97
  }
40
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Refresh pipeline queued" }), batchId && _jsxs(Text, { dimColor: true, children: ["batch: ", batchId] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar status --watch" }), " to monitor progress."] })] }));
98
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "green", children: ["\u2713 Refresh queued (", label, ")"] }), batchId && _jsxs(Text, { dimColor: true, children: ["batch: ", batchId] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar status --watch" }), " to monitor progress."] })] }));
41
99
  }
@@ -124,5 +124,5 @@ export default function Status({ options: flags }) {
124
124
  const BAR_WIDTH = 20;
125
125
  const filledCount = Math.round((embedPct / 100) * BAR_WIDTH);
126
126
  const progressBar = '█'.repeat(filledCount) + '░'.repeat(BAR_WIDTH - filledCount);
127
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: ["@", me.xHandle] }), _jsxs(Text, { dimColor: true, children: [me.indexedTweets.toLocaleString(), " tweets", ' · ', "indexed ", timeAgo(me.twitterIndexedAt), ' · ', "refreshed ", timeAgo(me.refreshedSuggestionsAt)] })] }), me.pendingEmbeddings > 0 && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "embeddings " }), _jsx(Text, { color: embedPct === 100 ? 'green' : 'yellow', children: progressBar }), _jsxs(Text, { dimColor: true, children: [" ", embedPct, "% "] }), _jsxs(Text, { dimColor: true, children: ["(", embedded.toLocaleString(), "/", me.indexedTweets.toLocaleString(), ")"] })] }) })), _jsxs(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [usage && (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan " }), _jsx(Text, { color: usage.plan === 'trial' ? 'yellow' : 'green', children: usage.plan })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "topics " }), _jsxs(Text, { color: usage.interests.atLimit ? 'red' : undefined, children: [usage.interests.used, usage.interests.limit !== null ? `/${usage.interests.limit}` : ''] })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "refreshes " }), usage.suggestionRefreshes.limit !== null ? (_jsxs(Text, { color: usage.suggestionRefreshes.atLimit ? 'red' : undefined, children: [usage.suggestionRefreshes.used, "/", usage.suggestionRefreshes.limit] })) : (_jsx(Text, { color: "green", children: "unlimited" }))] })] })), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "inbox " }), _jsx(Text, { color: suggestionCounts.inbox > 0 ? 'green' : undefined, children: suggestionCounts.inbox })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "later " }), suggestionCounts.later] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "archived " }), suggestionCounts.archived] })] })] }), pipeline && pipeline.status === 'running' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), pipeline.current !== '' && (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Spinner, { label: pipeline.current })] }))] })), pipeline && pipeline.status === 'complete' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsxs(Text, { color: "green", children: [" \u2713 Complete (", pipeline.total_duration, "s)"] })] })), pipeline && pipeline.status === 'failed' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsx(Text, { color: "red", children: " \u2717 Failed" })] })), me.pendingEmbeddings > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: 'Embeddings'.padEnd(16) }), _jsxs(Text, { color: "yellow", children: ["\u25CF ", me.pendingEmbeddings.toLocaleString(), " pending"] })] })), entries.filter(([name]) => name !== 'default').length > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "QUEUES" }), entries.filter(([name]) => name !== 'default').map(([name, counts]) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [" ", (QUEUE_LABELS[name] ?? name).padEnd(16)] }), counts.running > 0 && _jsxs(Text, { color: "green", children: ["\u25B6 ", counts.running, " running "] }), counts.queued > 0 && _jsxs(Text, { color: "yellow", children: ["\u25CF ", counts.queued, " queued "] }), (counts.deferred ?? 0) > 0 && _jsxs(Text, { color: "blue", children: ["\u25C6 ", counts.deferred, " pending "] })] }, name)))] })), !hasActivity && (_jsxs(Text, { dimColor: true, children: ["idle \u2014 run ", _jsx(Text, { color: "cyan", children: "sonar refresh" }), " to trigger pipeline"] })), flags.watch && (_jsxs(Box, { gap: 2, children: [refreshing && _jsx(Text, { color: "yellow", children: "refreshing..." }), refreshMsg && _jsx(Text, { color: "green", children: refreshMsg }), _jsxs(Text, { dimColor: true, children: ["press ", _jsx(Text, { color: "cyan", children: "r" }), " to refresh \u00B7 ", _jsx(Text, { color: "cyan", children: "q" }), " to quit"] })] }))] }));
127
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: ["@", me.xHandle] }), _jsxs(Text, { dimColor: true, children: [me.indexedTweets.toLocaleString(), " tweets", ' · ', "indexed ", timeAgo(me.twitterIndexedAt), ' · ', "refreshed ", timeAgo(me.refreshedSuggestionsAt)] })] }), me.pendingEmbeddings > 0 && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "embeddings " }), _jsx(Text, { color: embedPct === 100 ? 'green' : 'yellow', children: progressBar }), _jsxs(Text, { dimColor: true, children: [" ", embedPct, "% "] }), _jsxs(Text, { dimColor: true, children: ["(", embedded.toLocaleString(), "/", me.indexedTweets.toLocaleString(), ")"] })] }) })), _jsxs(Box, { borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [usage && (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan " }), _jsx(Text, { color: usage.plan === 'trial' ? 'yellow' : 'green', children: usage.plan })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "topics " }), _jsxs(Text, { color: usage.interests.atLimit ? 'red' : undefined, children: [usage.interests.used, usage.interests.limit !== null ? `/${usage.interests.limit}` : ''] })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "refreshes " }), usage.suggestionRefreshes.limit !== null ? (_jsxs(Text, { color: usage.suggestionRefreshes.atLimit ? 'red' : undefined, children: [usage.suggestionRefreshes.used, "/", usage.suggestionRefreshes.limit] })) : (_jsx(Text, { color: "green", children: "unlimited" }))] })] })), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "inbox " }), _jsx(Text, { color: suggestionCounts.inbox > 0 ? 'green' : undefined, children: suggestionCounts.inbox })] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "later " }), suggestionCounts.later] }), _jsx(Text, { dimColor: true, children: "\u2502" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "archived " }), suggestionCounts.archived] })] })] }), pipeline && pipeline.status === 'running' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), pipeline.current !== '' && (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsx(Spinner, { label: pipeline.current })] }))] })), pipeline && pipeline.status === 'complete' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsxs(Text, { color: "green", children: [" \u2713 Complete (", pipeline.total_duration, "s)"] })] })), pipeline && pipeline.status === 'failed' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "PIPELINE" }), pipeline.steps.map((step, i) => (_jsxs(Text, { children: [_jsx(Text, { color: "green", children: " \u2713 " }), _jsx(Text, { children: step.label }), _jsxs(Text, { dimColor: true, children: [" (", step.duration, "s)"] })] }, i))), _jsx(Text, { color: "red", children: " \u2717 Failed" }), pipeline.error && (_jsxs(Text, { color: "red", children: [" ", pipeline.error] })), (pipeline.error?.toLowerCase().includes('oauth') || pipeline.error?.toLowerCase().includes('authorization') || pipeline.error?.toLowerCase().includes('401') || pipeline.steps.length === 0) && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [" Re-connect your X account at ", _jsx(Text, { color: "cyan", children: "https://sonar.8640p.info/account" })] }), _jsxs(Text, { dimColor: true, children: [" Then run ", _jsx(Text, { color: "cyan", children: "sonar refresh" }), " to retry."] })] }))] })), me.pendingEmbeddings > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: 'Embeddings'.padEnd(16) }), _jsxs(Text, { color: "yellow", children: ["\u25CF ", me.pendingEmbeddings.toLocaleString(), " pending"] })] })), entries.filter(([name]) => name !== 'default').length > 0 && !(pipeline && pipeline.status === 'running') && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, dimColor: true, children: "QUEUES" }), entries.filter(([name]) => name !== 'default').map(([name, counts]) => (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [" ", (QUEUE_LABELS[name] ?? name).padEnd(16)] }), counts.running > 0 && _jsxs(Text, { color: "green", children: ["\u25B6 ", counts.running, " running "] }), counts.queued > 0 && _jsxs(Text, { color: "yellow", children: ["\u25CF ", counts.queued, " queued "] }), (counts.deferred ?? 0) > 0 && _jsxs(Text, { color: "blue", children: ["\u25C6 ", counts.deferred, " pending "] })] }, name)))] })), !hasActivity && (_jsxs(Text, { dimColor: true, children: ["idle \u2014 run ", _jsx(Text, { color: "cyan", children: "sonar refresh" }), " to trigger pipeline"] })), flags.watch && (_jsxs(Box, { gap: 2, children: [refreshing && _jsx(Text, { color: "yellow", children: "refreshing..." }), refreshMsg && _jsx(Text, { color: "green", children: refreshMsg }), _jsxs(Text, { dimColor: true, children: ["press ", _jsx(Text, { color: "cyan", children: "r" }), " to refresh \u00B7 ", _jsx(Text, { color: "cyan", children: "q" }), " to quit"] })] }))] }));
128
128
  }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useApp } from 'ink';
4
+ import { gql } from '../../lib/client.js';
5
+ import { Spinner } from '../../components/Spinner.js';
6
+ export default function SyncLikes() {
7
+ const { exit } = useApp();
8
+ const [status, setStatus] = useState('pending');
9
+ const [error, setError] = useState(null);
10
+ useEffect(() => {
11
+ async function run() {
12
+ setStatus('running');
13
+ try {
14
+ await gql('mutation SyncLikes { syncLikes }');
15
+ setStatus('ok');
16
+ }
17
+ catch (err) {
18
+ setStatus('failed');
19
+ setError(err instanceof Error ? err.message : String(err));
20
+ }
21
+ }
22
+ run();
23
+ }, []);
24
+ useEffect(() => {
25
+ if (status === 'ok' || status === 'failed')
26
+ exit();
27
+ }, [status]);
28
+ if (status === 'running') {
29
+ return _jsx(Spinner, { label: "Syncing likes..." });
30
+ }
31
+ if (status === 'failed') {
32
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
33
+ }
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", children: "\u2713 Likes sync queued" }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar status --watch" }), " to monitor progress."] })] }));
35
+ }
@@ -1,15 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
- // Sonar ping — radiates outward, resets
5
- const FRAMES = [' ', ' ', '·', '•', '●', '◉', '◎', '○', ' '];
4
+ import spinners from 'unicode-animations';
5
+ const SPINNER = spinners.pulse;
6
6
  export function Spinner({ label }) {
7
7
  const [frame, setFrame] = useState(0);
8
8
  useEffect(() => {
9
9
  const timer = setInterval(() => {
10
- setFrame((f) => (f + 1) % FRAMES.length);
11
- }, 100);
10
+ setFrame((f) => (f + 1) % SPINNER.frames.length);
11
+ }, SPINNER.interval);
12
12
  return () => clearInterval(timer);
13
13
  }, []);
14
- return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: FRAMES[frame] }), label ? _jsxs(Text, { children: [" ", label] }) : null] }));
14
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER.frames[frame] }), label ? _jsxs(Text, { children: [" ", label] }) : null] }));
15
15
  }
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { DB_PATH } from './db.js';
5
5
  const CONFIG_DIR = join(homedir(), '.sonar');
6
6
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+ const ACCOUNTS_FILE = join(CONFIG_DIR, 'accounts.json');
7
8
  export function readConfig() {
8
9
  try {
9
10
  const raw = readFileSync(CONFIG_FILE, 'utf8');
@@ -35,23 +36,65 @@ export function writeConfig(config) {
35
36
  mkdirSync(CONFIG_DIR, { recursive: true });
36
37
  writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), 'utf8');
37
38
  }
39
+ // ─── Accounts ────────────────────────────────────────────────────────────────
40
+ const DEFAULT_API_URL = 'https://api.sonar.8640p.info/graphql';
41
+ export function readAccounts() {
42
+ try {
43
+ const raw = readFileSync(ACCOUNTS_FILE, 'utf8');
44
+ return JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return { active: '', accounts: {} };
48
+ }
49
+ }
50
+ export function writeAccounts(data) {
51
+ mkdirSync(CONFIG_DIR, { recursive: true });
52
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), 'utf8');
53
+ }
54
+ export function accountsExist() {
55
+ return existsSync(ACCOUNTS_FILE);
56
+ }
57
+ /** Migrate legacy config.json token into accounts.json as "default". */
58
+ export function migrateToAccounts() {
59
+ if (accountsExist())
60
+ return;
61
+ const config = readConfig();
62
+ if (!config.token)
63
+ return;
64
+ writeAccounts({
65
+ active: 'default',
66
+ accounts: {
67
+ default: { token: config.token, apiUrl: config.apiUrl || DEFAULT_API_URL },
68
+ },
69
+ });
70
+ }
71
+ export function getActiveAccount() {
72
+ migrateToAccounts();
73
+ const { active, accounts } = readAccounts();
74
+ if (!active || !accounts[active])
75
+ return null;
76
+ return { name: active, ...accounts[active] };
77
+ }
38
78
  export function getToken() {
39
- // SONAR_API_KEY env var takes highest priority
40
- const apiKey = process.env.SONAR_API_KEY;
41
- if (apiKey)
42
- return apiKey;
79
+ // Check accounts.json
80
+ const account = getActiveAccount();
81
+ if (account?.token)
82
+ return account.token;
43
83
  // Fall back to config file token
44
84
  const config = readConfig();
45
85
  if (config.token)
46
86
  return config.token;
47
- process.stderr.write('No token found. Set SONAR_API_KEY or run: sonar config setup\n');
87
+ process.stderr.write('No token found. Run: sonar account add <name> <key>\n');
48
88
  process.exit(1);
49
89
  }
50
90
  export function getApiUrl() {
91
+ if (process.env.SONAR_API_URL)
92
+ return process.env.SONAR_API_URL;
93
+ const account = getActiveAccount();
94
+ if (account?.apiUrl)
95
+ return account.apiUrl;
51
96
  const config = readConfig();
52
- return (process.env.SONAR_API_URL ??
53
- config.apiUrl ??
54
- 'https://api.sonar.8640p.info/graphql');
97
+ return config.apiUrl ?? DEFAULT_API_URL;
55
98
  }
56
99
  export function getFeedRender(override) {
57
100
  return (override ??
@@ -2,20 +2,17 @@
2
2
  * Shared utilities for the data backup/restore/verify commands.
3
3
  */
4
4
  import { copyFileSync, existsSync, rmSync } from 'node:fs';
5
- import Database from 'better-sqlite3';
5
+ import pkg from 'node-sqlite3-wasm';
6
+ const { Database } = pkg;
6
7
  /**
7
8
  * Run SQLite's built-in integrity_check pragma on the given database file.
8
9
  * 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
10
  */
13
11
  export function integrityCheck(path) {
14
- const db = new Database(path, { readonly: true });
12
+ const db = new Database(path, { readOnly: true });
15
13
  try {
16
- const rows = db.pragma('integrity_check');
17
- const first = Object.values(rows[0] ?? {})[0];
18
- return String(first ?? 'unknown');
14
+ const row = db.get('PRAGMA integrity_check');
15
+ return row?.integrity_check ?? 'unknown';
19
16
  }
20
17
  finally {
21
18
  db.close();
@@ -23,8 +20,6 @@ export function integrityCheck(path) {
23
20
  }
24
21
  /**
25
22
  * 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
23
  */
29
24
  export function copyDbWithSidecars(src, dst) {
30
25
  copyFileSync(src, dst);
package/dist/lib/db.js CHANGED
@@ -1,4 +1,5 @@
1
- import Database from 'better-sqlite3';
1
+ import pkg from 'node-sqlite3-wasm';
2
+ const { Database } = pkg;
2
3
  import { mkdirSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
5
  import { join, dirname } from 'node:path';
@@ -26,9 +27,9 @@ export function openDb() {
26
27
  metadata TEXT,
27
28
  synced_at TEXT
28
29
  );
29
- CREATE TABLE IF NOT EXISTS interests (
30
+ CREATE TABLE IF NOT EXISTS topics (
30
31
  id TEXT PRIMARY KEY, name TEXT, description TEXT,
31
- keywords TEXT, topics TEXT,
32
+ keywords TEXT, related_topics TEXT,
32
33
  created_at TEXT, updated_at TEXT, synced_at TEXT
33
34
  );
34
35
  CREATE TABLE IF NOT EXISTS sync_state (
@@ -38,36 +39,46 @@ export function openDb() {
38
39
  return db;
39
40
  }
40
41
  export function upsertTweet(db, tweet) {
41
- db.prepare(`
42
+ db.run(`
42
43
  INSERT OR REPLACE INTO tweets
43
44
  (id, xid, text, created_at, like_count, retweet_count, reply_count,
44
45
  author_username, author_display_name, author_followers_count, author_following_count)
45
46
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
46
- `).run(tweet.id, tweet.xid, tweet.text, tweet.createdAt, tweet.likeCount, tweet.retweetCount, tweet.replyCount, tweet.user.username, tweet.user.displayName, tweet.user.followersCount, tweet.user.followingCount);
47
+ `, [tweet.id, tweet.xid, tweet.text, tweet.createdAt,
48
+ tweet.likeCount, tweet.retweetCount, tweet.replyCount,
49
+ tweet.user.username, tweet.user.displayName,
50
+ tweet.user.followersCount, tweet.user.followingCount]);
47
51
  }
48
52
  export function upsertFeedItem(db, item) {
49
- db.prepare(`
53
+ db.run(`
50
54
  INSERT OR REPLACE INTO feed_items (tweet_id, score, matched_keywords, synced_at)
51
55
  VALUES (?, ?, ?, ?)
52
- `).run(item.tweetId, item.score, JSON.stringify(item.matchedKeywords), new Date().toISOString());
56
+ `, [item.tweetId, item.score, JSON.stringify(item.matchedKeywords), new Date().toISOString()]);
53
57
  }
54
58
  export function upsertSuggestion(db, s) {
55
- db.prepare(`
59
+ db.run(`
56
60
  INSERT OR REPLACE INTO suggestions
57
61
  (suggestion_id, tweet_id, score, status, relevance, projects_matched, metadata, synced_at)
58
62
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
59
- `).run(s.suggestionId, s.tweetId, s.score, s.status, s.relevance, JSON.stringify(s.projectsMatched), s.metadata != null ? JSON.stringify(s.metadata) : null, new Date().toISOString());
63
+ `, [s.suggestionId, s.tweetId, s.score, s.status, s.relevance,
64
+ JSON.stringify(s.projectsMatched),
65
+ s.metadata != null ? JSON.stringify(s.metadata) : null,
66
+ new Date().toISOString()]);
60
67
  }
61
- export function upsertInterest(db, interest) {
62
- db.prepare(`
63
- INSERT OR REPLACE INTO interests (id, name, description, keywords, topics, created_at, updated_at, synced_at)
68
+ export function upsertTopic(db, topic) {
69
+ db.run(`
70
+ INSERT OR REPLACE INTO topics (id, name, description, keywords, related_topics, created_at, updated_at, synced_at)
64
71
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
65
- `).run(interest.id, interest.name, interest.description, JSON.stringify(interest.keywords ?? []), JSON.stringify(interest.relatedTopics ?? []), interest.createdAt, interest.updatedAt, new Date().toISOString());
72
+ `, [topic.id, topic.name, topic.description,
73
+ JSON.stringify(topic.keywords ?? []),
74
+ JSON.stringify(topic.relatedTopics ?? []),
75
+ topic.createdAt, topic.updatedAt,
76
+ new Date().toISOString()]);
66
77
  }
67
78
  export function getSyncState(db, key) {
68
- const row = db.prepare('SELECT value FROM sync_state WHERE key = ?').get(key);
79
+ const row = db.get('SELECT value FROM sync_state WHERE key = ?', [key]);
69
80
  return row?.value ?? null;
70
81
  }
71
82
  export function setSyncState(db, key, value) {
72
- db.prepare('INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)').run(key, value);
83
+ db.run('INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)', [key, value]);
73
84
  }
package/dist/lib/skill.js CHANGED
@@ -8,7 +8,7 @@ homepage: https://sonar.sh
8
8
  user-invocable: true
9
9
  allowed-tools: Bash
10
10
  argument-hint: [command and options]
11
- metadata: {"openclaw":{"emoji":"📡","primaryEnv":"SONAR_API_KEY","requires":{"bins":["sonar"],"env":["SONAR_API_KEY"]}}}
11
+ metadata: {"openclaw":{"emoji":"📡","requires":{"bins":["sonar"]}}}
12
12
  ---
13
13
 
14
14
  # Sonar CLI
@@ -101,7 +101,6 @@ sonar config nuke --confirm
101
101
 
102
102
  | Variable | Purpose |
103
103
  |---|---|
104
- | \`SONAR_API_KEY\` | API key for auth (overrides config file token) |
105
104
  | \`SONAR_API_URL\` | Backend URL (defaults to production GraphQL endpoint) |
106
105
  | \`SONAR_AI_VENDOR\` | Vendor override for AI-assisted operations (\`openai\` or \`anthropic\`) |
107
106
  | \`SONAR_FEED_RENDER\` | Default feed renderer override |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1a35e1/sonar-cli",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "X social graph CLI for signal filtering and curation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,21 +21,21 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
- "better-sqlite3": "^11",
25
24
  "date-fns": "4.1.0",
26
25
  "graphql": "^16.12.0",
27
26
  "graphql-request": "^7.4.0",
28
27
  "ink": "^6",
29
28
  "ink-link": "^5.0.0",
30
29
  "ink-table": "^3.1.0",
30
+ "node-sqlite3-wasm": "^0.8.55",
31
31
  "pastel": "^3.0.0",
32
32
  "react": "^19",
33
+ "unicode-animations": "^1.0.3",
33
34
  "zod": "^3.25.76"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@graphql-codegen/cli": "^5.0.5",
37
38
  "@graphql-codegen/typescript-graphql-request": "^6.4.0",
38
- "@types/better-sqlite3": "^7",
39
39
  "@types/node": "^22",
40
40
  "@types/react": "^19",
41
41
  "biome": "^0.3.3",
@@ -1,74 +0,0 @@
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
- }
@@ -1,53 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { Box, Text } from 'ink';
4
- import { unlinkSync, existsSync } from 'node:fs';
5
- import { gql } from '../../../lib/client.js';
6
- import { Spinner } from '../../../components/Spinner.js';
7
- import { DB_PATH, openDb, upsertTweet, upsertFeedItem, upsertSuggestion, upsertInterest, setSyncState, } from '../../../lib/db.js';
8
- import { FEED_QUERY, SUGGESTIONS_QUERY, INTERESTS_QUERY } from '../../../lib/data-queries.js';
9
- export default function DataDownload() {
10
- const [result, setResult] = useState(null);
11
- const [error, setError] = useState(null);
12
- useEffect(() => {
13
- async function run() {
14
- try {
15
- if (existsSync(DB_PATH))
16
- unlinkSync(DB_PATH);
17
- const db = openDb();
18
- const [feedResult, suggestionsResult, interestsResult] = await Promise.all([
19
- gql(FEED_QUERY, { hours: null, days: 7, limit: 500 }),
20
- gql(SUGGESTIONS_QUERY, { status: null, limit: 500 }),
21
- gql(INTERESTS_QUERY),
22
- ]);
23
- for (const item of feedResult.feed) {
24
- upsertTweet(db, item.tweet);
25
- upsertFeedItem(db, { tweetId: item.tweet.id, score: item.score, matchedKeywords: item.matchedKeywords });
26
- }
27
- for (const s of suggestionsResult.suggestions) {
28
- upsertTweet(db, s.tweet);
29
- upsertSuggestion(db, { suggestionId: s.suggestionId, tweetId: s.tweet.id, score: s.score, status: s.status, relevance: null, projectsMatched: s.projectsMatched });
30
- }
31
- for (const i of interestsResult.topics) {
32
- upsertInterest(db, i);
33
- }
34
- setSyncState(db, 'last_synced_at', new Date().toISOString());
35
- db.close();
36
- setResult({
37
- feedCount: feedResult.feed.length,
38
- suggestionsCount: suggestionsResult.suggestions.length,
39
- interestsCount: interestsResult.topics.length,
40
- });
41
- }
42
- catch (err) {
43
- setError(err instanceof Error ? err.message : String(err));
44
- }
45
- }
46
- run();
47
- }, []);
48
- if (error)
49
- return _jsxs(Text, { color: "red", children: ["Error: ", error] });
50
- if (!result)
51
- return _jsx(Spinner, { label: "Downloading data..." });
52
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Download complete" }), _jsxs(Text, { dimColor: true, children: [" ", DB_PATH] })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: result.feedCount }), _jsx(Text, { dimColor: true, children: " feed items " }), _jsx(Text, { color: "cyan", children: result.suggestionsCount }), _jsx(Text, { dimColor: true, children: " suggestions " }), _jsx(Text, { color: "cyan", children: result.interestsCount }), _jsx(Text, { dimColor: true, children: " interests" })] })] }));
53
- }