@praise166/vire 0.1.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/.env.example ADDED
@@ -0,0 +1,7 @@
1
+ DATABASE_URL=postgres://postgres:password@localhost:5432/vire
2
+ DISCORD_TOKEN=your.discord.token
3
+ VIRE_STATUS_URL=https://your-worker.your-subdomain.workers.dev
4
+ VIRE_DELETE_AFTER_SECONDS=8
5
+
6
+ # Default is false. When false, Vire blocks watched high-risk commands in server channels.
7
+ VIRE_ALLOW_SERVER_RISKY_COMMANDS=false
package/dist/vire.js ADDED
@@ -0,0 +1,562 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { spawn } from 'node:child_process';
4
+ import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ import process from 'node:process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { input, password, select } from '@inquirer/prompts';
9
+ import boxen from 'boxen';
10
+ import chalk from 'chalk';
11
+ import dotenv from 'dotenv';
12
+ import ora from 'ora';
13
+ import pg from 'pg';
14
+ const { Client } = pg;
15
+ process.on('warning', warning => {
16
+ if (warning.message.includes("The SSL modes 'prefer', 'require', and 'verify-ca'") ||
17
+ warning.code === 'DEP0190') {
18
+ return;
19
+ }
20
+ console.warn(warning);
21
+ });
22
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
23
+ const configDir = process.env.VIRE_HOME || join(process.env.APPDATA || process.env.HOME || root, 'vire');
24
+ await mkdir(configDir, { recursive: true });
25
+ await loadConfig();
26
+ const sessionsFile = join(configDir, 'sessions.json');
27
+ const localBuild = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
28
+ const bg = '#303030';
29
+ const panelOptions = {
30
+ borderColor: 'gray',
31
+ borderStyle: 'round',
32
+ padding: 1,
33
+ backgroundColor: bg,
34
+ };
35
+ const state = {
36
+ user: 'pending',
37
+ requests: 0,
38
+ globalAll: process.env.VIRE_ALLOW_SERVER_RISKY_COMMANDS === 'true',
39
+ last: 'booting',
40
+ };
41
+ function banner() {
42
+ console.clear();
43
+ console.log(boxen([
44
+ `${chalk.hex('#f59e0b')('*')} ${chalk.bold.white('vire.self')}`,
45
+ '',
46
+ `${chalk.dim('please enter your credentials below')}`,
47
+ ].join('\n'), panelOptions));
48
+ console.log(' /\\_/\\');
49
+ console.log('( o.o )', chalk.dim('.gg/vire3'));
50
+ console.log(' > ^ <');
51
+ console.log();
52
+ }
53
+ function statusLine() {
54
+ const width = process.stdout.columns || 100;
55
+ const text = [
56
+ ' vire.self',
57
+ `user: ${state.user}`,
58
+ `requests: ${state.requests}`,
59
+ `global all: ${state.globalAll}`,
60
+ `last: ${state.last}`,
61
+ ].join(' | ');
62
+ console.log(chalk.bgHex(bg).white(text.padEnd(width)));
63
+ }
64
+ function notice(kind, body) {
65
+ if (kind === 'dm' && shouldHideDm(body)) {
66
+ return;
67
+ }
68
+ const color = kind === 'blocked' || kind === 'server-alert'
69
+ ? chalk.red
70
+ : kind === 'dm'
71
+ ? chalk.cyan
72
+ : kind === 'request'
73
+ ? chalk.green
74
+ : kind === 'global'
75
+ ? chalk.yellow
76
+ : chalk.white;
77
+ const label = kind === 'request' ? formatRequest(body) : body;
78
+ console.log(`${color('*')} ${chalk.bold(kind)} ${chalk.dim(label)}`);
79
+ state.last = `${kind}: ${body}`;
80
+ }
81
+ function shouldHideDm(body) {
82
+ const message = body.split(':').slice(1).join(':').trim();
83
+ return !message || message.startsWith(',');
84
+ }
85
+ function formatRequest(body) {
86
+ return body.replace(/\s+used\s+\((\d+)\s+total\)/, ' ($1)');
87
+ }
88
+ function handleBotLine(raw) {
89
+ const line = raw.trim();
90
+ if (!line)
91
+ return;
92
+ const event = line.match(/^\[vire:([^\]]+)\]\s*(.*)$/);
93
+ if (event) {
94
+ const [, kind, body = ''] = event;
95
+ if (kind === 'request') {
96
+ const count = body.match(/\((\d+) total\)/);
97
+ if (count)
98
+ state.requests = Number(count[1]);
99
+ }
100
+ if (kind === 'login') {
101
+ state.user = body.replace(/\s+is connected$/, '');
102
+ }
103
+ if (kind === 'global') {
104
+ state.globalAll = body.includes('unlocked');
105
+ }
106
+ notice(kind, body);
107
+ return;
108
+ }
109
+ if (line.startsWith('+---') ||
110
+ line.startsWith('user:') ||
111
+ line.startsWith('requests made:') ||
112
+ line.startsWith('notifications:') ||
113
+ line.startsWith('global all:')) {
114
+ return;
115
+ }
116
+ console.log(chalk.dim(line));
117
+ }
118
+ function requireEnv(name) {
119
+ const value = process.env[name];
120
+ if (!value?.trim()) {
121
+ throw new Error(`${name} is required in .env`);
122
+ }
123
+ return value;
124
+ }
125
+ async function loadConfig() {
126
+ const userEnv = join(configDir, '.env');
127
+ const projectEnv = join(root, '.env');
128
+ let userEnvExists = true;
129
+ await access(userEnv).catch(() => {
130
+ userEnvExists = false;
131
+ });
132
+ if (!userEnvExists) {
133
+ await copyFile(projectEnv, userEnv).catch(() => undefined);
134
+ }
135
+ dotenv.config({ path: userEnv, override: false });
136
+ }
137
+ async function remoteStatus() {
138
+ const url = statusUrl();
139
+ if (!url?.trim()) {
140
+ throw new Error('vire status url is not configured');
141
+ }
142
+ const controller = new AbortController();
143
+ const timeout = setTimeout(() => controller.abort(), 5_000);
144
+ try {
145
+ const response = await fetch(url, { signal: controller.signal });
146
+ const body = (await response.json().catch(() => ({})));
147
+ return {
148
+ online: response.ok && body.online !== false,
149
+ auth: response.ok && body.auth !== false,
150
+ status: String(body.status ?? response.status),
151
+ message: String(body.message ?? response.statusText),
152
+ build: body.build ? String(body.build) : undefined,
153
+ download_url: body.download_url ? String(body.download_url) : undefined,
154
+ database_url: body.database_url ? String(body.database_url) : undefined,
155
+ delete_after_seconds: body.delete_after_seconds
156
+ ? String(body.delete_after_seconds)
157
+ : undefined,
158
+ image_api_url: body.image_api_url ? String(body.image_api_url) : undefined,
159
+ telemetry: body.telemetry === true,
160
+ };
161
+ }
162
+ catch (error) {
163
+ return {
164
+ online: false,
165
+ auth: false,
166
+ status: 'unreachable',
167
+ message: error instanceof Error ? error.message : 'status check failed',
168
+ build: undefined,
169
+ };
170
+ }
171
+ finally {
172
+ clearTimeout(timeout);
173
+ }
174
+ }
175
+ function statusUrl() {
176
+ return process.env.VIRE_STATUS_URL || localBuild.vire?.statusUrl;
177
+ }
178
+ function telemetryUrl() {
179
+ const url = statusUrl();
180
+ if (!url?.trim())
181
+ return undefined;
182
+ const target = new URL(url);
183
+ target.pathname = '/telemetry';
184
+ target.search = '';
185
+ return target.toString();
186
+ }
187
+ function applyRemoteConfig(status) {
188
+ if (status.database_url && !process.env.DATABASE_URL) {
189
+ process.env.DATABASE_URL = status.database_url;
190
+ }
191
+ if (status.delete_after_seconds && !process.env.VIRE_DELETE_AFTER_SECONDS) {
192
+ process.env.VIRE_DELETE_AFTER_SECONDS = status.delete_after_seconds;
193
+ }
194
+ if (status.image_api_url && !process.env.VIRE_IMAGE_API_URL) {
195
+ process.env.VIRE_IMAGE_API_URL = status.image_api_url;
196
+ }
197
+ const telemetry = telemetryUrl();
198
+ if (telemetry && !process.env.VIRE_TELEMETRY_URL) {
199
+ process.env.VIRE_TELEMETRY_URL = telemetry;
200
+ }
201
+ }
202
+ async function checkForUpdate(status) {
203
+ const remoteBuild = status.build?.trim();
204
+ const currentBuild = localBuild.version ?? '0.0.0';
205
+ if (!status.download_url) {
206
+ if (remoteBuild && remoteBuild !== currentBuild) {
207
+ ora().warn(`new build ${remoteBuild} available, no download url set`);
208
+ }
209
+ return null;
210
+ }
211
+ if (!remoteBuild) {
212
+ return downloadRuntime(status.download_url, 'latest');
213
+ }
214
+ if (remoteBuild === currentBuild) {
215
+ const existing = await downloadedRuntimePath(remoteBuild);
216
+ if (existing)
217
+ return existing;
218
+ return downloadRuntime(status.download_url, remoteBuild);
219
+ }
220
+ return downloadRuntime(status.download_url, remoteBuild);
221
+ }
222
+ async function downloadRuntime(url, build) {
223
+ const existing = await downloadedRuntimePath(build);
224
+ if (existing)
225
+ return existing;
226
+ if (!url) {
227
+ return null;
228
+ }
229
+ const spinner = ora(`downloading runtime ${build}`).start();
230
+ const response = await fetch(url);
231
+ if (!response.ok) {
232
+ spinner.fail(`download failed (${response.status})`);
233
+ return null;
234
+ }
235
+ const fileName = runtimeFileName(url, build);
236
+ const updates = join(root, 'updates');
237
+ const target = join(updates, fileName);
238
+ const bytes = Buffer.from(await response.arrayBuffer());
239
+ await mkdir(updates, { recursive: true });
240
+ await writeFile(target, bytes);
241
+ spinner.succeed(`downloaded runtime ${build}`);
242
+ return target;
243
+ }
244
+ function runtimeFileName(url, build) {
245
+ void url;
246
+ void build;
247
+ return process.platform === 'win32' ? 'vire.exe' : 'vire';
248
+ }
249
+ async function downloadedRuntimePath(build) {
250
+ const candidates = [
251
+ process.platform === 'win32' ? 'vire.exe' : 'vire',
252
+ process.platform === 'win32' ? `vire.self-${build}.exe` : `vire.self-${build}`,
253
+ process.platform === 'win32' ? 'vire.self.exe' : 'vire.self',
254
+ ];
255
+ for (const candidate of candidates) {
256
+ const path = join(root, 'updates', candidate);
257
+ let exists = true;
258
+ await access(path).catch(() => {
259
+ exists = false;
260
+ });
261
+ if (exists)
262
+ return path;
263
+ }
264
+ return null;
265
+ }
266
+ async function offlineScreen(status) {
267
+ const frames = ['(-.-) zzz', '(-_-) zz ', '(-.-) z ', '(-_-) zz '];
268
+ for (let i = 0; i < 16; i += 1) {
269
+ console.clear();
270
+ console.log(boxen([
271
+ `${chalk.hex('#f59e0b')('*')} ${chalk.bold.white('vire.self')}`,
272
+ '',
273
+ `${chalk.dim('status:')} ${chalk.red(status.status)}`,
274
+ `${chalk.dim('message:')} ${status.message}`,
275
+ '',
276
+ ' /\\_/\\',
277
+ ` ${frames[i % frames.length]}`,
278
+ ' > ^ <',
279
+ ].join('\n'), { ...panelOptions, borderColor: 'red' }));
280
+ await new Promise(resolve => setTimeout(resolve, 220));
281
+ }
282
+ console.log(chalk.dim('bye bye...'));
283
+ }
284
+ async function postgresLogin() {
285
+ const databaseUrl = requireEnv('DATABASE_URL');
286
+ const username = await input({ message: 'username' });
287
+ const pass = await password({ message: 'password', mask: '*' });
288
+ const spinner = ora('checking postgres login').start();
289
+ const client = new Client({ connectionString: databaseUrl });
290
+ try {
291
+ await client.connect();
292
+ await client.query('create extension if not exists pgcrypto with schema public');
293
+ const result = await client.query('select id from vire_users where username = $1 and password_hash = public.crypt($2::text, password_hash)', [username, pass]);
294
+ if (result.rowCount !== 1) {
295
+ spinner.fail('login rejected');
296
+ throw new Error('invalid username or password');
297
+ }
298
+ spinner.succeed(`logged in as ${chalk.bold(username)}`);
299
+ return { username };
300
+ }
301
+ finally {
302
+ await client.end().catch(() => undefined);
303
+ }
304
+ }
305
+ async function discordToken() {
306
+ const sessions = await loadSessions();
307
+ if (sessions.length > 0) {
308
+ const choice = await select({
309
+ message: 'session',
310
+ choices: [
311
+ ...sessions.map(session => ({
312
+ name: session.username,
313
+ value: `resume:${session.id}`,
314
+ })),
315
+ { name: 'add token', value: 'add' },
316
+ { name: 'change token', value: 'change' },
317
+ ],
318
+ });
319
+ if (choice.startsWith('resume:')) {
320
+ const session = sessions.find(item => item.id === choice.slice('resume:'.length));
321
+ if (session)
322
+ return { token: session.token, save: false };
323
+ }
324
+ if (choice === 'change') {
325
+ const id = await select({
326
+ message: 'change',
327
+ choices: sessions.map(session => ({
328
+ name: session.username,
329
+ value: session.id,
330
+ })),
331
+ });
332
+ const token = await password({ message: 'discord token', mask: '*' });
333
+ await saveSessions(sessions.filter(session => session.id !== id));
334
+ return { token, save: true };
335
+ }
336
+ }
337
+ if (process.env.DISCORD_TOKEN?.trim()) {
338
+ return { token: process.env.DISCORD_TOKEN, save: true };
339
+ }
340
+ return {
341
+ token: await password({ message: 'discord token', mask: '*' }),
342
+ save: true,
343
+ };
344
+ }
345
+ async function verifyDiscordToken(token) {
346
+ const response = await fetch('https://discord.com/api/v10/users/@me', {
347
+ headers: {
348
+ authorization: token,
349
+ },
350
+ });
351
+ if (!response.ok) {
352
+ const body = await response.text().catch(() => '');
353
+ throw new Error(body || 'discord token was rejected');
354
+ }
355
+ const user = (await response.json());
356
+ const id = user.id ?? user.username ?? 'unknown';
357
+ const avatarUrl = user.avatar && user.id
358
+ ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
359
+ : undefined;
360
+ return {
361
+ avatarUrl,
362
+ id,
363
+ username: user.username ?? user.id ?? 'unknown',
364
+ };
365
+ }
366
+ async function sendTelemetry(event) {
367
+ const url = process.env.VIRE_TELEMETRY_URL;
368
+ if (!url)
369
+ return;
370
+ await fetch(url, {
371
+ method: 'POST',
372
+ headers: {
373
+ 'content-type': 'application/json',
374
+ },
375
+ body: JSON.stringify(event),
376
+ }).catch(() => undefined);
377
+ }
378
+ async function loadSessions() {
379
+ return readFile(sessionsFile, 'utf8')
380
+ .then(content => JSON.parse(content))
381
+ .catch(() => []);
382
+ }
383
+ async function saveSessions(sessions) {
384
+ await writeFile(sessionsFile, JSON.stringify(sessions, null, 2));
385
+ }
386
+ async function saveSession(token, user) {
387
+ const sessions = await loadSessions();
388
+ const next = sessions.filter(session => session.id !== user.id);
389
+ next.push({
390
+ id: user.id,
391
+ username: user.username,
392
+ token,
393
+ updated_at: new Date().toISOString(),
394
+ });
395
+ await saveSessions(next);
396
+ }
397
+ async function ensureReleaseBinary(downloadedRuntime) {
398
+ if (downloadedRuntime)
399
+ return downloadedRuntime;
400
+ const exe = process.platform === 'win32' ? 'vire.exe' : 'vire';
401
+ const binary = join(root, 'target', 'release', exe);
402
+ let exists = true;
403
+ await access(binary).catch(() => {
404
+ exists = false;
405
+ });
406
+ if (!exists) {
407
+ const updateBinary = await newestDownloadedRuntime();
408
+ if (updateBinary)
409
+ return updateBinary;
410
+ }
411
+ if (!exists) {
412
+ throw new Error('runtime missing; upload the exe to r2 or place it in updates');
413
+ }
414
+ if (process.env.VIRE_FORCE_BUILD === 'true') {
415
+ const spinner = ora('preparing runtime').start();
416
+ await run('cargo', ['build', '--release'], spinner);
417
+ }
418
+ return binary;
419
+ }
420
+ async function newestDownloadedRuntime() {
421
+ const candidates = ['vire.exe', 'vire'];
422
+ for (const candidate of candidates) {
423
+ const path = join(root, 'updates', candidate);
424
+ let exists = true;
425
+ await access(path).catch(() => {
426
+ exists = false;
427
+ });
428
+ if (exists)
429
+ return path;
430
+ }
431
+ return null;
432
+ }
433
+ function run(command, args, spinner) {
434
+ return new Promise((resolve, reject) => {
435
+ const [bin, binArgs] = commandForPlatform(command, args);
436
+ const child = spawn(bin, binArgs, {
437
+ cwd: root,
438
+ shell: false,
439
+ stdio: ['ignore', 'pipe', 'pipe'],
440
+ });
441
+ let stderr = '';
442
+ child.stderr.on('data', chunk => {
443
+ stderr += chunk.toString();
444
+ });
445
+ child.on('error', error => {
446
+ spinner.fail(`${command} failed to start`);
447
+ reject(error);
448
+ });
449
+ child.on('exit', code => {
450
+ if (code === 0) {
451
+ spinner.succeed(spinner.text);
452
+ resolve();
453
+ }
454
+ else {
455
+ spinner.fail(`${command} exited with ${code}`);
456
+ reject(new Error(stderr.trim() || `${command} exited with ${code}`));
457
+ }
458
+ });
459
+ });
460
+ }
461
+ function commandForPlatform(command, args) {
462
+ if (process.platform !== 'win32') {
463
+ return [command, args];
464
+ }
465
+ return ['cmd.exe', ['/d', '/s', '/c', quoteCommand([command, ...args])]];
466
+ }
467
+ function quoteCommand(parts) {
468
+ return parts
469
+ .map(part => {
470
+ if (/^[a-zA-Z0-9_./:-]+$/.test(part)) {
471
+ return part;
472
+ }
473
+ return `"${part.replace(/"/g, '\\"')}"`;
474
+ })
475
+ .join(' ');
476
+ }
477
+ function startBot(binary, token, login) {
478
+ console.clear();
479
+ console.log(boxen([
480
+ `${chalk.bold.green('vire.self is starting')}`,
481
+ `${chalk.dim('user:')} ${login.username}`,
482
+ `${chalk.dim('requests:')} 0`,
483
+ `${chalk.dim('safety:')} use ,global all false to disable autonuke`,
484
+ ].join('\n'), { ...panelOptions, borderColor: 'cyan' }));
485
+ return new Promise((resolve, reject) => {
486
+ const child = spawn(binary, [], {
487
+ cwd: root,
488
+ shell: false,
489
+ stdio: ['inherit', 'pipe', 'pipe'],
490
+ env: {
491
+ ...process.env,
492
+ DISCORD_TOKEN: token,
493
+ VIRE_CLI_WRAPPED: 'true',
494
+ NO_COLOR: '1',
495
+ },
496
+ });
497
+ ora().succeed('vire.self is running');
498
+ let started = true;
499
+ child.stdout.setEncoding('utf8');
500
+ child.stderr.setEncoding('utf8');
501
+ child.stdout.on('data', chunk => {
502
+ for (const line of chunk.split(/\r?\n/)) {
503
+ if (!line.trim())
504
+ continue;
505
+ handleBotLine(line);
506
+ }
507
+ });
508
+ child.stderr.on('data', chunk => {
509
+ for (const line of chunk.split(/\r?\n/)) {
510
+ if (line.trim())
511
+ console.error(chalk.red(line.trim()));
512
+ }
513
+ });
514
+ child.on('error', error => {
515
+ reject(error);
516
+ });
517
+ child.on('exit', code => {
518
+ if (!started) {
519
+ console.log(chalk.red(`vire.self exited before connecting (${code ?? 0})`));
520
+ }
521
+ else {
522
+ console.log(chalk.dim(`vire.self exited with code ${code ?? 0}`));
523
+ }
524
+ resolve();
525
+ });
526
+ });
527
+ }
528
+ async function main() {
529
+ banner();
530
+ try {
531
+ const status = await remoteStatus();
532
+ if (!status.online || !status.auth) {
533
+ await offlineScreen(status);
534
+ return;
535
+ }
536
+ applyRemoteConfig(status);
537
+ const runtime = await checkForUpdate(status);
538
+ const login = await postgresLogin();
539
+ const selection = await discordToken();
540
+ const discordUser = await verifyDiscordToken(selection.token);
541
+ state.user = discordUser.username;
542
+ if (selection.save) {
543
+ await saveSession(selection.token, discordUser);
544
+ }
545
+ await sendTelemetry({
546
+ type: 'login',
547
+ service_user: login.username,
548
+ discord_username: discordUser.username,
549
+ discord_id: discordUser.id,
550
+ avatar_url: discordUser.avatarUrl,
551
+ });
552
+ console.log(chalk.dim(`logged in as ${discordUser.username}`));
553
+ const binary = await ensureReleaseBinary(runtime);
554
+ await startBot(binary, selection.token, login);
555
+ }
556
+ catch (error) {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+ console.error(chalk.red(`error: ${message}`));
559
+ process.exitCode = 1;
560
+ }
561
+ }
562
+ await main();
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@praise166/vire",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "vire": "./dist/vire.js",
8
+ "vire.self": "./dist/vire.js"
9
+ },
10
+ "vire": {
11
+ "statusUrl": "https://vire-self-status.your-subdomain.workers.dev"
12
+ },
13
+ "scripts": {
14
+ "dev": "tsx cli/vire.ts",
15
+ "start": "tsx cli/vire.ts",
16
+ "build": "tsc -p tsconfig.json",
17
+ "global": "npm run build && node scripts/install-global.mjs",
18
+ "exe": "node scripts/build-exe.mjs",
19
+ "installer:exe": "cargo build --release --bin vire-installer",
20
+ "worker:dev": "wrangler dev",
21
+ "worker:deploy": "wrangler deploy"
22
+ },
23
+ "dependencies": {
24
+ "@inquirer/prompts": "^7.3.2",
25
+ "boxen": "^8.0.1",
26
+ "chalk": "^5.4.1",
27
+ "dotenv": "^16.4.7",
28
+ "ora": "^8.1.1",
29
+ "pg": "^8.13.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.10.7",
33
+ "@types/pg": "^8.11.11",
34
+ "caxa": "^3.0.1",
35
+ "tsx": "^4.19.2",
36
+ "typescript": "^5.7.3",
37
+ "wrangler": "^4.18.0"
38
+ },
39
+ "pkg": {
40
+ "scripts": [
41
+ "dist/**/*.js"
42
+ ],
43
+ "assets": [
44
+ "target/release/vire.exe"
45
+ ]
46
+ }
47
+ }