@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 CHANGED
@@ -1,21 +1,14 @@
1
- # 🔊 Sonar (Preview)
1
+ # 🔊 Sonar (Alpha)
2
2
 
3
- Experimental X CLI for OpenClaw 🦞 power users.
3
+ Agent optimised [X](https://x.com) CLI for founders who want to stay ahead of the curve.
4
4
 
5
- Sonar matches interests from your X graph using various AI pipelines. We built this to automate our social intelligence.
5
+ We got tired of missing important content in our feed and built Sonar to fix it.
6
6
 
7
- This cli has been designed to handover indexing and consumption to agents.
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
- * Register with `X` to get an API key from `https://sonar.8640p.info/`
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
- # Make "SONAR_API_KEY" avaliable in your env
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 based on <<https://docs.x.com/fundamentals/authentication/oauth-2-0/>. If there is an appite
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
- In the early web2 era, API-first was a revelation. Stripe, Twilio, Sendgrid — companies that exposed clean REST contracts unlocked entire ecosystems of products built on top of them. The insight was simple: if your service has strong, reliable APIs, anyone can build anything. The interface didn't matter as much as the contract underneath.
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 signal
135
+ ## How Sonar finds relevant content
180
136
 
181
- Sonar surfaces relevant content from your X social graph — 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.
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
- export SONAR_API_KEY="your_api_key_here"
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 edit --id <id> --name "New Name"
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 # archive old suggestions
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
- ### Local Data
255
+ ### Sync
360
256
 
361
257
  ```bash
362
- sonar sync # sync data to local SQLite
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 | Required | Purpose |
370
- |---|---|---|
371
- | `SONAR_API_KEY` | Yes | Auth token from [sonar.8640p.info](https://sonar.8640p.info/) |
372
- | `SONAR_API_URL` | No | GraphQL endpoint (default: production API) |
373
- | `SONAR_MAX_RETRIES` | No | Max retry attempts on transient failures (default: 3, 0 to disable) |
374
- | `OPENAI_API_KEY` | For `topics suggest` | Required when using OpenAI vendor for AI suggestions |
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 | Contents |
380
- |---|---|
274
+ | Path | Contents |
275
+ | ---------------------- | ---------------------------- |
381
276
  | `~/.sonar/config.json` | Token, API URL, CLI defaults |
382
- | `~/.sonar/data.db` | Local synced SQLite database |
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. Set SONAR_API_KEY or run: sonar config setup`**
404
- 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/).
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 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.4",
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
- }