@1a35e1/sonar-cli 0.3.4 → 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 +39 -144
- package/dist/cli.js +14 -1
- package/dist/commands/account/add.js +58 -0
- package/dist/commands/account/index.js +30 -0
- package/dist/commands/account/remove.js +34 -0
- package/dist/commands/account/rename.js +35 -0
- package/dist/commands/account/switch.js +27 -0
- package/dist/commands/config/env.js +5 -7
- package/dist/commands/config/setup.js +2 -2
- package/dist/commands/data/backup.js +57 -0
- package/dist/commands/{config/data → data}/path.js +1 -1
- package/dist/commands/{config/data/sync.js → data/pull.js} +13 -13
- package/dist/commands/{config/data → data}/restore.js +2 -2
- package/dist/commands/{config/data → data}/sql.js +1 -1
- package/dist/commands/{config/data → data}/verify.js +2 -2
- package/dist/commands/refresh.js +63 -5
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync/likes.js +35 -0
- package/dist/components/Spinner.js +5 -5
- package/dist/lib/config.js +51 -8
- package/dist/{commands/config/data/utils.js → lib/data-utils.js} +5 -10
- package/dist/lib/db.js +26 -15
- package/dist/lib/skill.js +1 -2
- package/package.json +3 -3
- package/dist/commands/config/data/backup.js +0 -74
- package/dist/commands/config/data/download.js +0 -53
package/README.md
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
# 🔊 Sonar (
|
|
1
|
+
# 🔊 Sonar (Alpha)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Agent optimised [X](https://x.com) CLI for founders who want to stay ahead of the curve.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
We got tired of missing important content in our feed and built Sonar to fix it.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* Pipe it into scripts,
|
|
10
|
-
* automate your morning briefing,
|
|
11
|
-
* Or just discover tweets you probably missed out on the web interface.
|
|
12
|
-
|
|
13
|
-
---
|
|
7
|
+
Sonar matches your interests from your X network, filtering only relevant content from your graph using a variety of AI pipelines. We built this to automate our social intelligence at [@LighthouseGov](https://x.com/LighthouseGov).
|
|
14
8
|
|
|
15
9
|
## Get started
|
|
16
10
|
|
|
17
|
-
*
|
|
18
|
-
* Learn more about which [scopes](#scopes) we request and why.
|
|
11
|
+
* Login with your `X` account to obtain a [free API key](https://sonar.8640p.info/).
|
|
19
12
|
|
|
20
13
|
Install the CLI
|
|
21
14
|
|
|
@@ -26,11 +19,7 @@ pnpm add -g @1a35e1/sonar-cli@latest
|
|
|
26
19
|
Register your API key.
|
|
27
20
|
|
|
28
21
|
```sh
|
|
29
|
-
|
|
30
|
-
export SONAR_API_KEY=snr_xxxxx
|
|
31
|
-
|
|
32
|
-
# or, manually register
|
|
33
|
-
sonar config setup key=<YOUR_API_KEY>
|
|
22
|
+
sonar account add snr_xxxxx
|
|
34
23
|
```
|
|
35
24
|
|
|
36
25
|
View your account status:
|
|
@@ -52,54 +41,11 @@ sonar status --watch
|
|
|
52
41
|
|
|
53
42
|
## Scopes
|
|
54
43
|
|
|
55
|
-
* We currently request `read:*` and `offline:processing` scopes
|
|
56
|
-
|
|
57
|
-
* So we can stay connected to your account until you revoke access.
|
|
58
|
-
* Posts you’ve liked and likes you can view.
|
|
59
|
-
* All the posts you can view, including posts from protected accounts.
|
|
60
|
-
* Accounts you’ve muted.
|
|
61
|
-
* Accounts you’ve blocked.
|
|
62
|
-
* People who follow you and people who you follow.
|
|
63
|
-
* All your Bookmarks.
|
|
64
|
-
* Lists, list members, and list followers of lists you’ve created or are a member of, including private lists.
|
|
65
|
-
* Any account you can view, including protected accounts.
|
|
66
|
-
|
|
67
|
-
## Why Sonar exists
|
|
68
|
-
|
|
69
|
-
Setting up your own social data pipeline is genuinely awful. You're looking at OAuth flows, rate limit math, pagination handling, webhook plumbing, deduplication logic, and a SQLite schema you'll regret in three weeks — before you've seen a single useful result. Most developers who try it abandon it halfway through.
|
|
70
|
-
|
|
71
|
-
**Sonar skips all of that. Get actionalable data for OpenClaw in 15 minutes.**
|
|
72
|
-
|
|
73
|
-
We believe your data is yours. If you want to go deeper than our platform allows — build your own models, run custom queries, pipe it into your own tooling — you can sync everything we have indexed on your behalf into a local SQLite database:
|
|
74
|
-
|
|
75
|
-
```bash
|
|
76
|
-
sonar sync # sync data to ~/.sonar/data.db
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
No lock-in. If you outgrow us, you leave with your data intact.
|
|
80
|
-
|
|
81
|
-
## Design philosophy
|
|
44
|
+
* We currently request `read:*` and `offline:processing` scopes
|
|
45
|
+
* This allows us to read your feed, bookmarks, followers/following, and other account data to power our signal filtering and topic suggestions.
|
|
82
46
|
|
|
83
|
-
There's a quiet shift happening in how developer tools are built.
|
|
84
47
|
|
|
85
|
-
|
|
86
|
-
We're at a similar inflection point now, but the interface layer has changed dramatically.
|
|
87
|
-
|
|
88
|
-
The goal for most workflows today is fire and forget — you define what you want, set it in motion, and let agents handle the execution. That only works if the underlying APIs are strong enough to support complex, long-running ETL pipelines without hand-holding. Sonar is built with that assumption: the API is the product, the CLI is just one interface into it.
|
|
89
|
-
Which raises an interesting question about CLIs themselves. Traditionally a CLI was developer-first by definition — you were writing for someone comfortable with flags, pipes, and man pages. But if the primary consumer of your CLI is increasingly an agent (OpenClaw, a cron job, an LLM with tool access), the design principles shift:
|
|
90
|
-
|
|
91
|
-
Output should be machine-readable by default. Every command has a --json flag. Agents don't parse card renders.
|
|
92
|
-
Commands should be composable. Small, single-purpose commands that pipe cleanly into each other are more useful to an agent than monolithic workflows.
|
|
93
|
-
|
|
94
|
-
Side effects should be explicit. An agent calling index --force should know exactly what it's triggering. No surprises.
|
|
95
|
-
Errors should be structured. A human reads an error message. An agent needs to know whether to retry, skip, or escalate.
|
|
96
|
-
|
|
97
|
-
The CLI still needs to work well for humans — interactive mode, card renders, readable output — but those are progressive enhancements on top of a foundation built for automation. Design for the agent, polish for the human.
|
|
98
|
-
This is what API-first looks like in the agentic era: strong contracts at the service layer, composable interfaces at the CLI layer, and a clear separation between the two.
|
|
99
|
-
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
## What you can do with it
|
|
48
|
+
## Use cases
|
|
103
49
|
|
|
104
50
|
### Morning briefing in one command
|
|
105
51
|
|
|
@@ -174,11 +120,21 @@ sonar --no-interactive # disable for scripting
|
|
|
174
120
|
|
|
175
121
|
Mark suggestions as skip, later, or archive — keyboard-driven.
|
|
176
122
|
|
|
123
|
+
### Build your own filters and dashboards (WIP)
|
|
124
|
+
|
|
125
|
+
Download your data and build your own tools on top of it.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
sonar sync # sync data to ~/.sonar/data.db
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
No lock-in. If you outgrow us, you leave with your data intact.
|
|
132
|
+
|
|
177
133
|
---
|
|
178
134
|
|
|
179
|
-
## How Sonar finds
|
|
135
|
+
## How Sonar finds relevant content
|
|
180
136
|
|
|
181
|
-
Sonar surfaces relevant content from your
|
|
137
|
+
Sonar surfaces relevant content from your immediate network — the people you follow and who follow you. Your network is already a curated signal layer. Sonar's job is to surface what's moving through that graph before it reaches mainstream feeds.
|
|
182
138
|
|
|
183
139
|
What this means in practice:
|
|
184
140
|
|
|
@@ -187,67 +143,6 @@ What this means in practice:
|
|
|
187
143
|
* Bookmarking and liking content improves your recommendations over time
|
|
188
144
|
* Topics sharpen what Sonar surfaces within your graph
|
|
189
145
|
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## Pair with OpenClaw
|
|
193
|
-
|
|
194
|
-
[OpenClaw](https://github.com/openclaw/openclaw) is a local-first autonomous AI agent that runs on your machine and talks to you through WhatsApp, Telegram, Discord, Slack, or iMessage. It can execute shell commands, run on a schedule, and be extended with custom skills.
|
|
195
|
-
|
|
196
|
-
Sonar + OpenClaw is a natural stack: **Sonar handles the signal filtering and curation, OpenClaw handles delivery and action.** Together they turn your social feed into an ambient intelligence layer you don't have to babysit.
|
|
197
|
-
|
|
198
|
-
### Morning briefing delivered to your phone
|
|
199
|
-
|
|
200
|
-
Set up a cron job in OpenClaw to run your Sonar digest every morning:
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
# In OpenClaw: schedule a daily 8am briefing
|
|
204
|
-
"Every morning at 8am, run `sonar --hours 8 --json` and summarize the top 5 posts for me"
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
OpenClaw will execute the CLI, pass the JSON output to your LLM, and send a clean summary straight to your phone.
|
|
208
|
-
|
|
209
|
-
### Ask your feed questions in natural language
|
|
210
|
-
|
|
211
|
-
Because `--json` makes Sonar output composable, OpenClaw can reason over it:
|
|
212
|
-
|
|
213
|
-
```
|
|
214
|
-
# Example prompts you can send OpenClaw via WhatsApp:
|
|
215
|
-
"What's the most discussed topic in my Sonar feed today?"
|
|
216
|
-
"Did anyone in my feed mention Uniswap V4 in the last 48 hours?"
|
|
217
|
-
"Summarize my Sonar suggestions"
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
### Get alerted when a topic spikes
|
|
221
|
-
|
|
222
|
-
Use OpenClaw's Heartbeat to watch for signal surges:
|
|
223
|
-
|
|
224
|
-
```
|
|
225
|
-
# OpenClaw cron: check every 2 hours
|
|
226
|
-
"Run `sonar --hours 2 --json` — if there are more than 10 posts about
|
|
227
|
-
'token launchpad' or 'LVR', send me a Telegram alert with the highlights"
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### Build a Sonar skill for OpenClaw
|
|
231
|
-
|
|
232
|
-
Wrap Sonar as a reusable OpenClaw skill:
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
// skills/sonar.ts
|
|
236
|
-
export async function getSuggestions(hours = 12) {
|
|
237
|
-
const { stdout } = await exec(`sonar --hours ${hours} --json`);
|
|
238
|
-
return JSON.parse(stdout);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export async function getStatus() {
|
|
242
|
-
const { stdout } = await exec(`sonar status --json`);
|
|
243
|
-
return JSON.parse(stdout);
|
|
244
|
-
}
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
Once registered, OpenClaw can call these tools autonomously whenever it decides they're relevant.
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
146
|
## Setup
|
|
252
147
|
|
|
253
148
|
### Prerequisites
|
|
@@ -261,8 +156,7 @@ Once registered, OpenClaw can call these tools autonomously whenever it decides
|
|
|
261
156
|
```bash
|
|
262
157
|
pnpm add -g @1a35e1/sonar-cli@latest
|
|
263
158
|
|
|
264
|
-
|
|
265
|
-
sonar config setup key=<YOUR_API_KEY>
|
|
159
|
+
sonar account add <YOUR_API_KEY>
|
|
266
160
|
```
|
|
267
161
|
|
|
268
162
|
Verify it works:
|
|
@@ -317,7 +211,9 @@ Press `q` to quit follow mode.
|
|
|
317
211
|
sonar topics # list all topics
|
|
318
212
|
sonar topics --json # JSON output
|
|
319
213
|
sonar topics add "AI agents" # add a topic
|
|
320
|
-
sonar topics
|
|
214
|
+
sonar topics view <id> # view a topic
|
|
215
|
+
sonar topics edit <id> --name "New Name"
|
|
216
|
+
sonar topics delete <id> # delete a topic
|
|
321
217
|
```
|
|
322
218
|
|
|
323
219
|
#### AI-powered topic suggestions
|
|
@@ -346,7 +242,7 @@ sonar status --watch # poll every 2s
|
|
|
346
242
|
```bash
|
|
347
243
|
sonar skip --id <suggestion_id> # skip a suggestion
|
|
348
244
|
sonar later --id <suggestion_id> # save for later
|
|
349
|
-
sonar archive
|
|
245
|
+
sonar archive --id <suggestion_id> # archive a suggestion
|
|
350
246
|
```
|
|
351
247
|
|
|
352
248
|
### Config
|
|
@@ -356,30 +252,29 @@ sonar config # show current config
|
|
|
356
252
|
sonar config setup key=<API_KEY> # set API key
|
|
357
253
|
```
|
|
358
254
|
|
|
359
|
-
###
|
|
255
|
+
### Sync
|
|
360
256
|
|
|
361
257
|
```bash
|
|
362
|
-
sonar sync
|
|
258
|
+
sonar sync bookmarks # sync bookmarks to local SQLite
|
|
363
259
|
```
|
|
364
260
|
|
|
365
261
|
---
|
|
366
262
|
|
|
367
263
|
## Environment Variables
|
|
368
264
|
|
|
369
|
-
| Variable
|
|
370
|
-
|
|
371
|
-
| `
|
|
372
|
-
| `
|
|
373
|
-
| `
|
|
374
|
-
| `
|
|
375
|
-
| `ANTHROPIC_API_KEY` | For `topics suggest` | Required when using Anthropic vendor for AI suggestions |
|
|
265
|
+
| Variable | Required | Purpose |
|
|
266
|
+
| ------------------- | -------------------- | ------------------------------------------------------------------- |
|
|
267
|
+
| `SONAR_API_URL` | No | GraphQL endpoint (default: production API) |
|
|
268
|
+
| `SONAR_MAX_RETRIES` | No | Max retry attempts on transient failures (default: 3, 0 to disable) |
|
|
269
|
+
| `OPENAI_API_KEY` | For `topics suggest` | Required when using OpenAI vendor for AI suggestions |
|
|
270
|
+
| `ANTHROPIC_API_KEY` | For `topics suggest` | Required when using Anthropic vendor for AI suggestions |
|
|
376
271
|
|
|
377
272
|
## Local Files
|
|
378
273
|
|
|
379
|
-
| Path
|
|
380
|
-
|
|
274
|
+
| Path | Contents |
|
|
275
|
+
| ---------------------- | ---------------------------- |
|
|
381
276
|
| `~/.sonar/config.json` | Token, API URL, CLI defaults |
|
|
382
|
-
| `~/.sonar/data.db`
|
|
277
|
+
| `~/.sonar/data.db` | Local synced SQLite database |
|
|
383
278
|
|
|
384
279
|
---
|
|
385
280
|
|
|
@@ -400,8 +295,8 @@ Locally, it skips when offline; in CI (`CI=true`) it is enforced.
|
|
|
400
295
|
|
|
401
296
|
## Troubleshooting
|
|
402
297
|
|
|
403
|
-
**`No token found.
|
|
404
|
-
|
|
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/).
|
|
405
300
|
|
|
406
301
|
**`Unable to reach server, please try again shortly.`**
|
|
407
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
|
|
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(`
|
|
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
|
|
15
|
+
const apiKey = flags.key;
|
|
16
16
|
const apiUrl = process.env.SONAR_API_URL;
|
|
17
17
|
if (!apiKey) {
|
|
18
|
-
process.stderr.write('
|
|
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 '
|
|
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 '
|
|
6
|
-
import { Spinner } from '
|
|
7
|
-
import { DB_PATH, openDb, upsertTweet, upsertFeedItem, upsertSuggestion,
|
|
8
|
-
import { FEED_QUERY, SUGGESTIONS_QUERY, INTERESTS_QUERY } from '
|
|
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
|
|
36
|
-
|
|
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,
|
|
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.
|
|
49
|
-
const prevSuggestionsCount = db.
|
|
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.
|
|
60
|
-
const newSuggestionsCount = db.
|
|
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
|
-
|
|
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.
|
|
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 '
|
|
8
|
-
import { integrityCheck, copyDbWithSidecars } from '
|
|
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 '
|
|
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 '
|
|
7
|
-
import { integrityCheck } from '
|
|
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'),
|
package/dist/commands/refresh.js
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
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: [
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
const
|
|
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) %
|
|
11
|
-
},
|
|
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:
|
|
14
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: SPINNER.frames[frame] }), label ? _jsxs(Text, { children: [" ", label] }) : null] }));
|
|
15
15
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
//
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
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.
|
|
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
|
|
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
|
|
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, {
|
|
12
|
+
const db = new Database(path, { readOnly: true });
|
|
15
13
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
|
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
|
|
30
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
30
31
|
id TEXT PRIMARY KEY, name TEXT, description TEXT,
|
|
31
|
-
keywords 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.
|
|
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
|
-
|
|
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.
|
|
53
|
+
db.run(`
|
|
50
54
|
INSERT OR REPLACE INTO feed_items (tweet_id, score, matched_keywords, synced_at)
|
|
51
55
|
VALUES (?, ?, ?, ?)
|
|
52
|
-
|
|
56
|
+
`, [item.tweetId, item.score, JSON.stringify(item.matchedKeywords), new Date().toISOString()]);
|
|
53
57
|
}
|
|
54
58
|
export function upsertSuggestion(db, s) {
|
|
55
|
-
db.
|
|
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
|
-
|
|
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
|
|
62
|
-
db.
|
|
63
|
-
INSERT OR REPLACE INTO
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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":"📡","
|
|
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
|
+
"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
|
-
}
|