@1a35e1/sonar-cli 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -10
- 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/config/skill.js +2 -1
- 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 +26 -11
- 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
|
@@ -19,11 +19,7 @@ pnpm add -g @1a35e1/sonar-cli@latest
|
|
|
19
19
|
Register your API key.
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
|
-
|
|
23
|
-
export SONAR_API_KEY=snr_xxxxx
|
|
24
|
-
|
|
25
|
-
# or, manually register
|
|
26
|
-
sonar config setup key=<YOUR_API_KEY>
|
|
22
|
+
sonar account add snr_xxxxx
|
|
27
23
|
```
|
|
28
24
|
|
|
29
25
|
View your account status:
|
|
@@ -160,8 +156,7 @@ What this means in practice:
|
|
|
160
156
|
```bash
|
|
161
157
|
pnpm add -g @1a35e1/sonar-cli@latest
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
sonar config setup key=<YOUR_API_KEY>
|
|
159
|
+
sonar account add <YOUR_API_KEY>
|
|
165
160
|
```
|
|
166
161
|
|
|
167
162
|
Verify it works:
|
|
@@ -269,7 +264,6 @@ sonar sync bookmarks # sync bookmarks to local SQLite
|
|
|
269
264
|
|
|
270
265
|
| Variable | Required | Purpose |
|
|
271
266
|
| ------------------- | -------------------- | ------------------------------------------------------------------- |
|
|
272
|
-
| `SONAR_API_KEY` | Yes | Auth token from [sonar.8640p.info](https://sonar.8640p.info/) |
|
|
273
267
|
| `SONAR_API_URL` | No | GraphQL endpoint (default: production API) |
|
|
274
268
|
| `SONAR_MAX_RETRIES` | No | Max retry attempts on transient failures (default: 3, 0 to disable) |
|
|
275
269
|
| `OPENAI_API_KEY` | For `topics suggest` | Required when using OpenAI vendor for AI suggestions |
|
|
@@ -301,8 +295,8 @@ Locally, it skips when offline; in CI (`CI=true`) it is enforced.
|
|
|
301
295
|
|
|
302
296
|
## Troubleshooting
|
|
303
297
|
|
|
304
|
-
**`No token found.
|
|
305
|
-
|
|
298
|
+
**`No token found. Run: sonar account add <name> <key>`**
|
|
299
|
+
Add an account with `sonar account add <YOUR_KEY>`. Get a key at [sonar.8640p.info](https://sonar.8640p.info/).
|
|
306
300
|
|
|
307
301
|
**`Unable to reach server, please try again shortly.`**
|
|
308
302
|
Check your network connection and API availability. The CLI automatically retries transient failures (network errors, 5xx) up to 3 times with exponential backoff. Use `--debug` to see retry attempts. Set `SONAR_MAX_RETRIES=0` to disable retries.
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
2
5
|
import Pastel from 'pastel';
|
|
3
|
-
const
|
|
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({
|
|
@@ -6,10 +6,11 @@ import { writeSkillTo } from '../../lib/skill.js';
|
|
|
6
6
|
export const options = zod.object({
|
|
7
7
|
install: zod.boolean().default(false).describe('Install to ~/.claude/skills/sonar/SKILL.md'),
|
|
8
8
|
dest: zod.string().optional().describe('Write to a custom path'),
|
|
9
|
+
force: zod.boolean().default(false).describe('Overwrite even if file was modified'),
|
|
9
10
|
});
|
|
10
11
|
export default function Skill({ options: flags }) {
|
|
11
12
|
useEffect(() => {
|
|
12
|
-
writeSkillTo(flags.dest, flags.install);
|
|
13
|
+
writeSkillTo(flags.dest, flags.install, flags.force);
|
|
13
14
|
}, []);
|
|
14
15
|
return _jsx(Text, { dimColor: true, children: "Generating SKILL.md..." });
|
|
15
16
|
}
|
|
@@ -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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { join, dirname } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
const SKILL_CONTENT = `---
|
|
@@ -8,7 +9,7 @@ homepage: https://sonar.sh
|
|
|
8
9
|
user-invocable: true
|
|
9
10
|
allowed-tools: Bash
|
|
10
11
|
argument-hint: [command and options]
|
|
11
|
-
metadata: {"openclaw":{"emoji":"📡","
|
|
12
|
+
metadata: {"openclaw":{"emoji":"📡","requires":{"bins":["sonar"]}}}
|
|
12
13
|
---
|
|
13
14
|
|
|
14
15
|
# Sonar CLI
|
|
@@ -101,7 +102,6 @@ sonar config nuke --confirm
|
|
|
101
102
|
|
|
102
103
|
| Variable | Purpose |
|
|
103
104
|
|---|---|
|
|
104
|
-
| \`SONAR_API_KEY\` | API key for auth (overrides config file token) |
|
|
105
105
|
| \`SONAR_API_URL\` | Backend URL (defaults to production GraphQL endpoint) |
|
|
106
106
|
| \`SONAR_AI_VENDOR\` | Vendor override for AI-assisted operations (\`openai\` or \`anthropic\`) |
|
|
107
107
|
| \`SONAR_FEED_RENDER\` | Default feed renderer override |
|
|
@@ -110,18 +110,33 @@ sonar config nuke --confirm
|
|
|
110
110
|
| \`ANTHROPIC_API_KEY\` | Required when vendor is \`anthropic\` |
|
|
111
111
|
`;
|
|
112
112
|
const DEFAULT_INSTALL_PATH = join(homedir(), '.claude', 'skills', 'sonar', 'SKILL.md');
|
|
113
|
-
|
|
113
|
+
function sha256(content) {
|
|
114
|
+
return createHash('sha256').update(content).digest('hex');
|
|
115
|
+
}
|
|
116
|
+
function safeWrite(target, content, force) {
|
|
117
|
+
if (existsSync(target) && !force) {
|
|
118
|
+
const existing = readFileSync(target, 'utf8');
|
|
119
|
+
if (existing === content) {
|
|
120
|
+
process.stdout.write(`SKILL.md is already up to date: ${target}\n`);
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
// File exists and differs — user may have customized it
|
|
124
|
+
process.stderr.write(`SKILL.md has been modified: ${target}\n` +
|
|
125
|
+
`Use --force to overwrite, or manually merge.\n` +
|
|
126
|
+
`New version hash: ${sha256(content).slice(0, 8)}\n`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
130
|
+
writeFileSync(target, content, 'utf8');
|
|
131
|
+
process.stdout.write(`SKILL.md written to ${target}\n`);
|
|
132
|
+
}
|
|
133
|
+
export function writeSkillTo(dest, install, force) {
|
|
114
134
|
if (install || dest === '--install') {
|
|
115
|
-
|
|
116
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
117
|
-
writeFileSync(target, SKILL_CONTENT, 'utf8');
|
|
118
|
-
process.stdout.write(`SKILL.md written to ${target}\n`);
|
|
135
|
+
safeWrite(DEFAULT_INSTALL_PATH, SKILL_CONTENT, force ?? false);
|
|
119
136
|
process.exit(0);
|
|
120
137
|
}
|
|
121
138
|
if (dest) {
|
|
122
|
-
|
|
123
|
-
writeFileSync(dest, SKILL_CONTENT, 'utf8');
|
|
124
|
-
process.stdout.write(`SKILL.md written to ${dest}\n`);
|
|
139
|
+
safeWrite(dest, SKILL_CONTENT, force ?? false);
|
|
125
140
|
process.exit(0);
|
|
126
141
|
}
|
|
127
142
|
// Default: print to stdout
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1a35e1/sonar-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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
|
-
}
|