@ryopc/koshi 0.2.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/LICENSE +21 -0
- package/README.md +419 -0
- package/bin/cli.js +827 -0
- package/bin/server.js +109 -0
- package/package.json +66 -0
- package/src/auth/ed25519.js +91 -0
- package/src/auth/jwt.js +100 -0
- package/src/auth/utils.js +46 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// koshi ā Terminal-Native Decentralized SNS
|
|
4
|
+
// CLI/TUI Entry Point
|
|
5
|
+
// License: MIT
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// The `kb` command-line interface. Provides all user-facing commands
|
|
8
|
+
// for interacting with the koshi board: register, login, post, feed,
|
|
9
|
+
// follow, dm, profile, search, and more.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// kb <command> [options]
|
|
13
|
+
// kb --help
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import ora from 'ora';
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Constants
|
|
25
|
+
// ============================================================================
|
|
26
|
+
const CONFIG_DIR = join(homedir(), '.config', 'koshi');
|
|
27
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
28
|
+
const SNSRC_FILE = join(homedir(), '.snsrc');
|
|
29
|
+
const API_BASE = process.env.KOSHI_API_URL || 'https://koshi-api.ryopc.f5.si';
|
|
30
|
+
const WS_URL = process.env.KOSHI_WS_URL || 'wss://koshi-api.ryopc.f5.si';
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Helper: load configuration
|
|
34
|
+
// ============================================================================
|
|
35
|
+
function loadConfig() {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(CONFIG_FILE)) {
|
|
38
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
// Fallback to legacy .snsrc
|
|
41
|
+
if (existsSync(SNSRC_FILE)) {
|
|
42
|
+
const data = readFileSync(SNSRC_FILE, 'utf-8').trim();
|
|
43
|
+
if (data) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(data);
|
|
46
|
+
} catch {
|
|
47
|
+
// Plain text format: first line is secretKey, second line is username
|
|
48
|
+
const lines = data.split('\n');
|
|
49
|
+
if (lines.length >= 2) {
|
|
50
|
+
return {
|
|
51
|
+
secretKey: lines[0].trim(),
|
|
52
|
+
username: lines[1].trim(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Config file doesn't exist or is corrupt
|
|
60
|
+
}
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// Helper: save configuration
|
|
66
|
+
// ============================================================================
|
|
67
|
+
async function saveConfig(config) {
|
|
68
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
69
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
72
|
+
// Also save to .snsrc for legacy compatibility
|
|
73
|
+
writeFileSync(SNSRC_FILE, `${config.secretKey}\n${config.username}\n`, 'utf-8');
|
|
74
|
+
// Set restrictive permissions
|
|
75
|
+
try {
|
|
76
|
+
const { chmod } = await import('node:fs/promises');
|
|
77
|
+
await chmod(CONFIG_FILE, 0o600);
|
|
78
|
+
await chmod(SNSRC_FILE, 0o600);
|
|
79
|
+
} catch {
|
|
80
|
+
// chmod not critical
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Helper: HTTP request with auth
|
|
86
|
+
// ============================================================================
|
|
87
|
+
async function apiRequest(method, path, body = null, token = null) {
|
|
88
|
+
const url = `${API_BASE}${path}`;
|
|
89
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
90
|
+
if (token) {
|
|
91
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const response = await fetch(url, {
|
|
95
|
+
method,
|
|
96
|
+
headers,
|
|
97
|
+
body: body ? JSON.stringify(body) : null,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const data = await response.json();
|
|
101
|
+
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
throw new Error(data.message || data.error || `HTTP ${response.status}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return data;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ============================================================================
|
|
110
|
+
// Helper: resolve username to user ID
|
|
111
|
+
// ============================================================================
|
|
112
|
+
async function resolveUsername(username, token) {
|
|
113
|
+
const data = await apiRequest('GET', `/api/users/${username}`, null, token);
|
|
114
|
+
return data.id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Command: register
|
|
119
|
+
// ============================================================================
|
|
120
|
+
async function cmdRegister(args) {
|
|
121
|
+
const username = args[0];
|
|
122
|
+
if (!username) {
|
|
123
|
+
console.error(chalk.red('ā Error: Username is required. Usage: kb register <username>'));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const spinner = ora('Generating ed25519 keypair...').start();
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Dynamically import crypto modules
|
|
131
|
+
const { generateKeypair, derivePublicKey } = await import('../src/auth/ed25519.js');
|
|
132
|
+
const keypair = generateKeypair();
|
|
133
|
+
|
|
134
|
+
spinner.text = 'Registering with server...';
|
|
135
|
+
|
|
136
|
+
const result = await apiRequest('POST', '/api/auth/register', {
|
|
137
|
+
username,
|
|
138
|
+
publicKey: keypair.publicKey,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Store credentials
|
|
142
|
+
await saveConfig({
|
|
143
|
+
userId: result.userId,
|
|
144
|
+
username,
|
|
145
|
+
publicKey: keypair.publicKey,
|
|
146
|
+
secretKey: keypair.secretKey,
|
|
147
|
+
token: result.token,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
spinner.succeed(chalk.green('Registration successful!'));
|
|
151
|
+
|
|
152
|
+
console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(username)}`);
|
|
153
|
+
console.log(` ${chalk.bold('User ID:')} ${chalk.dim(result.userId)}`);
|
|
154
|
+
console.log(` ${chalk.bold('Token:')} ${chalk.dim(result.token.substring(0, 40))}...`);
|
|
155
|
+
console.log(`\n ${chalk.dim('Keys stored in:')} ${chalk.italic(CONFIG_FILE)}`);
|
|
156
|
+
console.log(` ${chalk.green('ā')} You are now logged in.`);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
spinner.fail(chalk.red(`Registration failed: ${err.message}`));
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Command: login
|
|
165
|
+
// ============================================================================
|
|
166
|
+
async function cmdLogin(args) {
|
|
167
|
+
const username = args[0];
|
|
168
|
+
if (!username) {
|
|
169
|
+
console.error(chalk.red('ā Error: Username is required. Usage: kb login <username>'));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const config = loadConfig();
|
|
174
|
+
|
|
175
|
+
if (!config.secretKey) {
|
|
176
|
+
console.error(chalk.red('ā Error: No secret key found.'));
|
|
177
|
+
console.error(chalk.dim(' Use "kb register <username>" to create a new account,'));
|
|
178
|
+
console.error(chalk.dim(' or import an existing key to ~/.snsrc.'));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const spinner = ora('Signing authentication challenge...').start();
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const { signMessage } = await import('../src/auth/ed25519.js');
|
|
186
|
+
const challenge = `koshi:login:${username}`;
|
|
187
|
+
const signature = await signMessage(challenge, config.secretKey);
|
|
188
|
+
|
|
189
|
+
spinner.text = 'Authenticating with server...';
|
|
190
|
+
|
|
191
|
+
const result = await apiRequest('POST', '/api/auth/login', {
|
|
192
|
+
username,
|
|
193
|
+
signature,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Update stored config
|
|
197
|
+
config.username = username;
|
|
198
|
+
config.userId = result.userId;
|
|
199
|
+
config.token = result.token;
|
|
200
|
+
await saveConfig(config);
|
|
201
|
+
|
|
202
|
+
spinner.succeed(chalk.green('Login successful!'));
|
|
203
|
+
|
|
204
|
+
console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(username)}`);
|
|
205
|
+
console.log(` ${chalk.bold('Token:')} ${chalk.dim(result.token.substring(0, 40))}...`);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
spinner.fail(chalk.red(`Login failed: ${err.message}`));
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// Command: whoami
|
|
214
|
+
// ============================================================================
|
|
215
|
+
async function cmdWhoami() {
|
|
216
|
+
const config = loadConfig();
|
|
217
|
+
|
|
218
|
+
if (!config.token || !config.username) {
|
|
219
|
+
console.error(chalk.red('ā Not logged in. Use "kb login <username>" or "kb register <username>".'));
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const spinner = ora('Fetching profile...').start();
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const data = await apiRequest('GET', `/api/users/${config.username}`, null, config.token);
|
|
227
|
+
spinner.stop();
|
|
228
|
+
|
|
229
|
+
console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
|
|
230
|
+
if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
|
|
231
|
+
if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
|
|
232
|
+
console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
|
|
233
|
+
console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
|
|
234
|
+
console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
spinner.fail(chalk.red(`Failed to fetch profile: ${err.message}`));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ============================================================================
|
|
242
|
+
// Command: post
|
|
243
|
+
// ============================================================================
|
|
244
|
+
async function cmdPost(args) {
|
|
245
|
+
const content = args.join(' ');
|
|
246
|
+
if (!content) {
|
|
247
|
+
console.error(chalk.red('ā Error: Content is required. Usage: kb post <message>'));
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (content.length > 2000) {
|
|
252
|
+
console.error(chalk.red(`ā Error: Content exceeds 2000 characters (${content.length}).`));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const config = loadConfig();
|
|
257
|
+
|
|
258
|
+
if (!config.token || !config.secretKey) {
|
|
259
|
+
console.error(chalk.red('ā Not logged in. Use "kb login <username>" or "kb register <username>".'));
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const spinner = ora('Signing and posting...').start();
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const { signMessage } = await import('../src/auth/ed25519.js');
|
|
267
|
+
const signature = await signMessage(content, config.secretKey);
|
|
268
|
+
|
|
269
|
+
spinner.text = 'Submitting to koshi board...';
|
|
270
|
+
|
|
271
|
+
const result = await apiRequest('POST', '/api/posts', {
|
|
272
|
+
content,
|
|
273
|
+
signature,
|
|
274
|
+
}, config.token);
|
|
275
|
+
|
|
276
|
+
spinner.succeed(chalk.green('Post created!'));
|
|
277
|
+
|
|
278
|
+
console.log(`\n ${chalk.dim('ID:')} ${chalk.dim(result.id)}`);
|
|
279
|
+
console.log(` ${chalk.dim('Posted:')} ${new Date(result.createdAt).toLocaleString()}`);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
spinner.fail(chalk.red(`Failed to post: ${err.message}`));
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ============================================================================
|
|
287
|
+
// Command: feed
|
|
288
|
+
// ============================================================================
|
|
289
|
+
async function cmdFeed(args) {
|
|
290
|
+
const config = loadConfig();
|
|
291
|
+
const limit = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1]) || 20;
|
|
292
|
+
const flags = args.filter((a) => !a.startsWith('--'));
|
|
293
|
+
|
|
294
|
+
const spinner = ora('Fetching feed...').start();
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const data = await apiRequest('GET', `/api/posts/feed?limit=${limit}`, null, config.token || null);
|
|
298
|
+
spinner.stop();
|
|
299
|
+
|
|
300
|
+
if (data.length === 0) {
|
|
301
|
+
console.log(chalk.dim('\n No posts in your feed. Follow some users or be the first to post!'));
|
|
302
|
+
console.log(chalk.dim(' Try: kb post "Hello, koshi!"'));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log(`\n ${chalk.bold.cyan('š Koshi Board Feed')} ${chalk.dim(`(${data.length} posts)`)}`);
|
|
307
|
+
console.log(` ${chalk.dim('ā'.repeat(60))}\n`);
|
|
308
|
+
|
|
309
|
+
for (const post of data) {
|
|
310
|
+
const displayName = post.author.displayName || post.author.username;
|
|
311
|
+
const time = new Date(post.createdAt).toLocaleString();
|
|
312
|
+
|
|
313
|
+
console.log(` ${chalk.bold(displayName)} ${chalk.dim(`@${post.author.username}`)}`);
|
|
314
|
+
console.log(` ${chalk.dim(time)}`);
|
|
315
|
+
console.log(` ${post.content}`);
|
|
316
|
+
console.log(` ${chalk.dim('ā'.repeat(60))}\n`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (data.length === limit) {
|
|
320
|
+
console.log(chalk.dim(` Use kb feed --limit=${limit + 20} to see more.`));
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
spinner.fail(chalk.red(`Failed to fetch feed: ${err.message}`));
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Command: follow
|
|
330
|
+
// ============================================================================
|
|
331
|
+
async function cmdFollow(args) {
|
|
332
|
+
const username = args[0];
|
|
333
|
+
if (!username) {
|
|
334
|
+
console.error(chalk.red('ā Error: Username is required. Usage: kb follow <username>'));
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const config = loadConfig();
|
|
339
|
+
if (!config.token) {
|
|
340
|
+
console.error(chalk.red('ā Not logged in.'));
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const spinner = ora(`Resolving @${username}...`).start();
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
|
|
348
|
+
spinner.text = `Following @${username}...`;
|
|
349
|
+
|
|
350
|
+
await apiRequest('POST', `/api/users/${userData.id}/follow`, {}, config.token);
|
|
351
|
+
|
|
352
|
+
spinner.succeed(chalk.green(`You are now following @${username}!`));
|
|
353
|
+
} catch (err) {
|
|
354
|
+
spinner.fail(chalk.red(`Failed to follow: ${err.message}`));
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ============================================================================
|
|
360
|
+
// Command: unfollow
|
|
361
|
+
// ============================================================================
|
|
362
|
+
async function cmdUnfollow(args) {
|
|
363
|
+
const username = args[0];
|
|
364
|
+
if (!username) {
|
|
365
|
+
console.error(chalk.red('ā Error: Username is required. Usage: kb unfollow <username>'));
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const config = loadConfig();
|
|
370
|
+
if (!config.token) {
|
|
371
|
+
console.error(chalk.red('ā Not logged in.'));
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const spinner = ora(`Resolving @${username}...`).start();
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
|
|
379
|
+
spinner.text = `Unfollowing @${username}...`;
|
|
380
|
+
|
|
381
|
+
await apiRequest('DELETE', `/api/users/${userData.id}/follow`, {}, config.token);
|
|
382
|
+
|
|
383
|
+
spinner.succeed(chalk.yellow(`Unfollowed @${username}.`));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
spinner.fail(chalk.red(`Failed to unfollow: ${err.message}`));
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// Command: dm
|
|
392
|
+
// ============================================================================
|
|
393
|
+
async function cmdDm(args) {
|
|
394
|
+
const username = args[0];
|
|
395
|
+
const message = args.slice(1).join(' ');
|
|
396
|
+
|
|
397
|
+
if (!username || !message) {
|
|
398
|
+
console.error(chalk.red('ā Error: Usage: kb dm <username> <message>'));
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const config = loadConfig();
|
|
403
|
+
if (!config.token || !config.secretKey) {
|
|
404
|
+
console.error(chalk.red('ā Not logged in.'));
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const spinner = ora(`Sending DM to @${username}...`).start();
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
// Resolve recipient
|
|
412
|
+
const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
|
|
413
|
+
|
|
414
|
+
// Sign message
|
|
415
|
+
const { signMessage } = await import('../src/auth/ed25519.js');
|
|
416
|
+
const signature = await signMessage(message, config.secretKey);
|
|
417
|
+
|
|
418
|
+
spinner.text = 'Encrypting and sending...';
|
|
419
|
+
|
|
420
|
+
const result = await apiRequest('POST', `/api/dms/${userData.id}`, {
|
|
421
|
+
content: message,
|
|
422
|
+
signature,
|
|
423
|
+
}, config.token);
|
|
424
|
+
|
|
425
|
+
spinner.succeed(chalk.green(`DM sent to @${username}!`));
|
|
426
|
+
console.log(` ${chalk.dim('ID:')} ${chalk.dim(result.id)}`);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
spinner.fail(chalk.red(`Failed to send DM: ${err.message}`));
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ============================================================================
|
|
434
|
+
// Command: dms
|
|
435
|
+
// ============================================================================
|
|
436
|
+
async function cmdDms(args) {
|
|
437
|
+
const config = loadConfig();
|
|
438
|
+
if (!config.token) {
|
|
439
|
+
console.error(chalk.red('ā Not logged in.'));
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const unreadOnly = args.includes('--unread');
|
|
444
|
+
const limit = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1]) || 50;
|
|
445
|
+
|
|
446
|
+
const spinner = ora('Fetching DMs...').start();
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
const data = await apiRequest(
|
|
450
|
+
'GET',
|
|
451
|
+
`/api/dms?limit=${limit}${unreadOnly ? '&unread=true' : ''}`,
|
|
452
|
+
null,
|
|
453
|
+
config.token
|
|
454
|
+
);
|
|
455
|
+
spinner.stop();
|
|
456
|
+
|
|
457
|
+
if (data.length === 0) {
|
|
458
|
+
console.log(chalk.dim('\n No messages in your inbox.'));
|
|
459
|
+
if (!unreadOnly) {
|
|
460
|
+
console.log(chalk.dim(' To send a DM: kb dm <username> <message>'));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const title = unreadOnly ? 'šØ Unread Messages' : 'šØ Direct Messages';
|
|
466
|
+
console.log(`\n ${chalk.bold.cyan(title)} ${chalk.dim(`(${data.length} messages)`)}`);
|
|
467
|
+
console.log(` ${chalk.dim('ā'.repeat(60))}\n`);
|
|
468
|
+
|
|
469
|
+
for (const dm of data) {
|
|
470
|
+
const isFromMe = dm.from.id === config.userId;
|
|
471
|
+
const displayName = isFromMe ? dm.to.displayName || dm.to.username : dm.from.displayName || dm.from.username;
|
|
472
|
+
const handle = isFromMe ? `@${dm.to.username}` : `@${dm.from.username}`;
|
|
473
|
+
const time = new Date(dm.createdAt).toLocaleString();
|
|
474
|
+
const readStatus = dm.isRead ? '' : chalk.yellow(' ā');
|
|
475
|
+
|
|
476
|
+
if (isFromMe) {
|
|
477
|
+
console.log(` ${chalk.dim('ā To:')} ${chalk.bold(displayName)} ${chalk.dim(handle)}${readStatus}`);
|
|
478
|
+
} else {
|
|
479
|
+
console.log(` ${chalk.dim('ā From:')} ${chalk.bold(displayName)} ${chalk.dim(handle)}${readStatus}`);
|
|
480
|
+
}
|
|
481
|
+
console.log(` ${chalk.dim(time)}`);
|
|
482
|
+
console.log(` ${dm.content}`);
|
|
483
|
+
console.log(` ${chalk.dim('ā'.repeat(60))}\n`);
|
|
484
|
+
}
|
|
485
|
+
} catch (err) {
|
|
486
|
+
spinner.fail(chalk.red(`Failed to fetch DMs: ${err.message}`));
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ============================================================================
|
|
492
|
+
// Command: profile
|
|
493
|
+
// ============================================================================
|
|
494
|
+
async function cmdProfile(args) {
|
|
495
|
+
const config = loadConfig();
|
|
496
|
+
const targetUsername = args[0] || config.username;
|
|
497
|
+
|
|
498
|
+
if (!targetUsername) {
|
|
499
|
+
console.error(chalk.red('ā Error: No username specified and not logged in.'));
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const spinner = ora(`Fetching profile @${targetUsername}...`).start();
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const data = await apiRequest('GET', `/api/users/${targetUsername}`, null, config.token || null);
|
|
507
|
+
spinner.stop();
|
|
508
|
+
|
|
509
|
+
console.log(`\n ${chalk.bold.cyan('š¤ Profile')}`);
|
|
510
|
+
console.log(` ${chalk.dim('ā'.repeat(40))}`);
|
|
511
|
+
console.log(` ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
|
|
512
|
+
if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
|
|
513
|
+
if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
|
|
514
|
+
if (data.avatarUrl) console.log(` ${chalk.bold('Avatar:')} ${chalk.dim(data.avatarUrl)}`);
|
|
515
|
+
console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
|
|
516
|
+
console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
|
|
517
|
+
console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
spinner.fail(chalk.red(`Failed to fetch profile: ${err.message}`));
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ============================================================================
|
|
525
|
+
// Command: search
|
|
526
|
+
// ============================================================================
|
|
527
|
+
async function cmdSearch(args) {
|
|
528
|
+
const query = args.join(' ');
|
|
529
|
+
if (!query || query.length < 2) {
|
|
530
|
+
console.error(chalk.red('ā Error: Search query must be at least 2 characters. Usage: kb search <query>'));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const config = loadConfig();
|
|
535
|
+
const spinner = ora(`Searching for "${query}"...`).start();
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const data = await apiRequest('GET', `/api/users/search/${encodeURIComponent(query)}`, null, config.token || null);
|
|
539
|
+
spinner.stop();
|
|
540
|
+
|
|
541
|
+
if (data.length === 0) {
|
|
542
|
+
console.log(chalk.dim(`\n No users found matching "${query}".`));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
console.log(`\n ${chalk.bold.cyan('š Search Results')} ${chalk.dim(`for "${query}"`)}`);
|
|
547
|
+
console.log(` ${chalk.dim('ā'.repeat(50))}\n`);
|
|
548
|
+
|
|
549
|
+
for (const user of data) {
|
|
550
|
+
const displayName = user.displayName || '(no display name)';
|
|
551
|
+
console.log(` ${chalk.bold(user.username)} ${chalk.dim(`ā ${displayName}`)}`);
|
|
552
|
+
}
|
|
553
|
+
console.log();
|
|
554
|
+
} catch (err) {
|
|
555
|
+
spinner.fail(chalk.red(`Search failed: ${err.message}`));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ============================================================================
|
|
561
|
+
// Command: help
|
|
562
|
+
// ============================================================================
|
|
563
|
+
function showHelp(command = null) {
|
|
564
|
+
const commands = {
|
|
565
|
+
register: {
|
|
566
|
+
usage: 'kb register <username>',
|
|
567
|
+
desc: 'Create a new account with ed25519 keypair',
|
|
568
|
+
},
|
|
569
|
+
login: {
|
|
570
|
+
usage: 'kb login <username>',
|
|
571
|
+
desc: 'Authenticate using existing keypair',
|
|
572
|
+
},
|
|
573
|
+
whoami: {
|
|
574
|
+
usage: 'kb whoami',
|
|
575
|
+
desc: 'Show your profile information',
|
|
576
|
+
},
|
|
577
|
+
post: {
|
|
578
|
+
usage: 'kb post <message>',
|
|
579
|
+
desc: 'Create a new post on the koshi board',
|
|
580
|
+
},
|
|
581
|
+
feed: {
|
|
582
|
+
usage: 'kb feed [--limit=20]',
|
|
583
|
+
desc: 'Display your post feed',
|
|
584
|
+
},
|
|
585
|
+
follow: {
|
|
586
|
+
usage: 'kb follow <username>',
|
|
587
|
+
desc: 'Follow a user',
|
|
588
|
+
},
|
|
589
|
+
unfollow: {
|
|
590
|
+
usage: 'kb unfollow <username>',
|
|
591
|
+
desc: 'Unfollow a user',
|
|
592
|
+
},
|
|
593
|
+
dm: {
|
|
594
|
+
usage: 'kb dm <username> <message>',
|
|
595
|
+
desc: 'Send a direct message',
|
|
596
|
+
},
|
|
597
|
+
dms: {
|
|
598
|
+
usage: 'kb dms [--unread] [--limit=50]',
|
|
599
|
+
desc: 'View your direct messages',
|
|
600
|
+
},
|
|
601
|
+
profile: {
|
|
602
|
+
usage: 'kb profile [username]',
|
|
603
|
+
desc: 'View a user profile (default: your own)',
|
|
604
|
+
},
|
|
605
|
+
search: {
|
|
606
|
+
usage: 'kb search <query>',
|
|
607
|
+
desc: 'Search users by username or display name',
|
|
608
|
+
},
|
|
609
|
+
help: {
|
|
610
|
+
usage: 'kb help [command]',
|
|
611
|
+
desc: 'Show this help message',
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
if (command && commands[command]) {
|
|
616
|
+
const cmd = commands[command];
|
|
617
|
+
console.log(`\n ${chalk.bold.cyan(`kb ${command}`)}`);
|
|
618
|
+
console.log(` ${chalk.dim('ā'.repeat(40))}`);
|
|
619
|
+
console.log(` ${chalk.bold('Usage:')} ${cmd.usage}`);
|
|
620
|
+
console.log(` ${chalk.bold('Description:')} ${cmd.desc}`);
|
|
621
|
+
console.log();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
console.log(`\n ${chalk.bold.cyan('š koshi ā Terminal-Native Decentralized SNS')}`);
|
|
626
|
+
console.log(` ${chalk.dim('Version 0.2.0')}`);
|
|
627
|
+
console.log(`\n ${chalk.bold('Usage:')} kb <command> [options]\n`);
|
|
628
|
+
console.log(` ${chalk.bold('Commands:')}\n`);
|
|
629
|
+
|
|
630
|
+
const maxLen = Math.max(...Object.values(commands).map((c) => c.usage.length));
|
|
631
|
+
|
|
632
|
+
for (const [name, cmd] of Object.entries(commands)) {
|
|
633
|
+
const padding = ' '.repeat(maxLen - cmd.usage.length + 2);
|
|
634
|
+
console.log(` ${chalk.cyan(cmd.usage)}${padding}${cmd.desc}`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
console.log(`\n ${chalk.bold('Options:')}`);
|
|
638
|
+
console.log(` --help, -h Show help for a command`);
|
|
639
|
+
console.log(` --version, -v Show version`);
|
|
640
|
+
console.log(`\n ${chalk.dim('Environment:')}`);
|
|
641
|
+
console.log(` ${chalk.dim('KOSHI_API_URL API base URL (default: https://koshi-api.ryopc.f5.si)')}`);
|
|
642
|
+
console.log(` ${chalk.dim('KOSHI_WS_URL WebSocket URL (default: wss://koshi-api.ryopc.f5.si)')}`);
|
|
643
|
+
console.log();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ============================================================================
|
|
647
|
+
// Command: version
|
|
648
|
+
// ============================================================================
|
|
649
|
+
function showVersion() {
|
|
650
|
+
console.log('koshi v0.2.0');
|
|
651
|
+
console.log('Terminal-native decentralized SNS');
|
|
652
|
+
console.log('License: MIT');
|
|
653
|
+
console.log('Author: game_ryo');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ============================================================================
|
|
657
|
+
// Command: realtime feed (stream mode)
|
|
658
|
+
// ============================================================================
|
|
659
|
+
async function cmdRealtime() {
|
|
660
|
+
const config = loadConfig();
|
|
661
|
+
if (!config.token) {
|
|
662
|
+
console.error(chalk.red('ā Not logged in. Use "kb login" or "kb register" first.'));
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
console.log(`\n ${chalk.bold.cyan('š” Connecting to realtime feed...')}`);
|
|
667
|
+
console.log(` ${chalk.dim('Press Ctrl+C to disconnect')}\n`);
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const { default: WebSocket } = await import('ws');
|
|
671
|
+
const ws = new WebSocket(`${WS_URL}/ws?token=${config.token}`);
|
|
672
|
+
|
|
673
|
+
ws.on('open', () => {
|
|
674
|
+
console.log(chalk.green(' ā Connected! Waiting for new posts...\n'));
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
ws.on('message', (data) => {
|
|
678
|
+
try {
|
|
679
|
+
const msg = JSON.parse(data.toString());
|
|
680
|
+
|
|
681
|
+
switch (msg.type) {
|
|
682
|
+
case 'connected':
|
|
683
|
+
// Initial connection confirmation
|
|
684
|
+
break;
|
|
685
|
+
|
|
686
|
+
case 'post_created':
|
|
687
|
+
console.log(` ${chalk.bold.cyan('š New Post')}`);
|
|
688
|
+
console.log(` ${chalk.bold(msg.payload.author.username)} ${chalk.dim(new Date(msg.payload.timestamp).toLocaleTimeString())}`);
|
|
689
|
+
console.log(` ${msg.payload.content}`);
|
|
690
|
+
console.log(` ${chalk.dim('ā'.repeat(50))}\n`);
|
|
691
|
+
break;
|
|
692
|
+
|
|
693
|
+
case 'dm_received':
|
|
694
|
+
console.log(` ${chalk.bold.yellow('ā New DM from')} ${chalk.bold(msg.payload.from.username)}`);
|
|
695
|
+
console.log(` ${msg.payload.content}`);
|
|
696
|
+
console.log(` ${chalk.dim('ā'.repeat(50))}\n`);
|
|
697
|
+
break;
|
|
698
|
+
|
|
699
|
+
case 'user_online':
|
|
700
|
+
console.log(` ${chalk.green('š¢')} ${chalk.bold(msg.payload.username)} ${chalk.dim('is online')}`);
|
|
701
|
+
break;
|
|
702
|
+
|
|
703
|
+
case 'user_offline':
|
|
704
|
+
console.log(` ${chalk.red('š“')} ${chalk.dim(`${msg.payload.userId} went offline`)}`);
|
|
705
|
+
break;
|
|
706
|
+
|
|
707
|
+
case 'pong':
|
|
708
|
+
// Ignore pong
|
|
709
|
+
break;
|
|
710
|
+
|
|
711
|
+
default:
|
|
712
|
+
console.log(` ${chalk.dim(`[${msg.type}]`)}`, msg.payload);
|
|
713
|
+
}
|
|
714
|
+
} catch {
|
|
715
|
+
// Ignore parse errors
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
ws.on('close', () => {
|
|
720
|
+
console.log(chalk.yellow('\n Disconnected from realtime feed.'));
|
|
721
|
+
process.exit(0);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
ws.on('error', (err) => {
|
|
725
|
+
console.error(chalk.red(`\n WebSocket error: ${err.message}`));
|
|
726
|
+
process.exit(1);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// Keep the process alive until Ctrl+C
|
|
730
|
+
process.on('SIGINT', () => {
|
|
731
|
+
console.log(chalk.dim('\n Closing connection...'));
|
|
732
|
+
ws.close();
|
|
733
|
+
process.exit(0);
|
|
734
|
+
});
|
|
735
|
+
} catch (err) {
|
|
736
|
+
console.error(chalk.red(`Failed to connect: ${err.message}`));
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// Main CLI dispatcher
|
|
743
|
+
// ============================================================================
|
|
744
|
+
async function main() {
|
|
745
|
+
const args = process.argv.slice(2);
|
|
746
|
+
const command = args[0];
|
|
747
|
+
|
|
748
|
+
// No args
|
|
749
|
+
if (!command || command === '--help' || command === '-h') {
|
|
750
|
+
showHelp(args[1]);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (command === '--version' || command === '-v') {
|
|
755
|
+
showVersion();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Dispatch commands
|
|
760
|
+
switch (command) {
|
|
761
|
+
case 'register':
|
|
762
|
+
await cmdRegister(args.slice(1));
|
|
763
|
+
break;
|
|
764
|
+
|
|
765
|
+
case 'login':
|
|
766
|
+
await cmdLogin(args.slice(1));
|
|
767
|
+
break;
|
|
768
|
+
|
|
769
|
+
case 'whoami':
|
|
770
|
+
await cmdWhoami();
|
|
771
|
+
break;
|
|
772
|
+
|
|
773
|
+
case 'post':
|
|
774
|
+
await cmdPost(args.slice(1));
|
|
775
|
+
break;
|
|
776
|
+
|
|
777
|
+
case 'feed':
|
|
778
|
+
await cmdFeed(args.slice(1));
|
|
779
|
+
break;
|
|
780
|
+
|
|
781
|
+
case 'follow':
|
|
782
|
+
await cmdFollow(args.slice(1));
|
|
783
|
+
break;
|
|
784
|
+
|
|
785
|
+
case 'unfollow':
|
|
786
|
+
await cmdUnfollow(args.slice(1));
|
|
787
|
+
break;
|
|
788
|
+
|
|
789
|
+
case 'dm':
|
|
790
|
+
await cmdDm(args.slice(1));
|
|
791
|
+
break;
|
|
792
|
+
|
|
793
|
+
case 'dms':
|
|
794
|
+
await cmdDms(args.slice(1));
|
|
795
|
+
break;
|
|
796
|
+
|
|
797
|
+
case 'profile':
|
|
798
|
+
await cmdProfile(args.slice(1));
|
|
799
|
+
break;
|
|
800
|
+
|
|
801
|
+
case 'search':
|
|
802
|
+
await cmdSearch(args.slice(1));
|
|
803
|
+
break;
|
|
804
|
+
|
|
805
|
+
case 'realtime':
|
|
806
|
+
case 'stream':
|
|
807
|
+
await cmdRealtime();
|
|
808
|
+
break;
|
|
809
|
+
|
|
810
|
+
case 'help':
|
|
811
|
+
showHelp(args[1]);
|
|
812
|
+
break;
|
|
813
|
+
|
|
814
|
+
default:
|
|
815
|
+
console.error(chalk.red(`ā Unknown command: "${command}"`));
|
|
816
|
+
console.error(chalk.dim(' Run "kb help" to see available commands.'));
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
main().catch((err) => {
|
|
822
|
+
console.error(chalk.red(`\nā Fatal error: ${err.message}`));
|
|
823
|
+
if (process.env.DEBUG) {
|
|
824
|
+
console.error(err.stack);
|
|
825
|
+
}
|
|
826
|
+
process.exit(1);
|
|
827
|
+
});
|