linkgravity 1.3.0 → 1.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/bin/cli.js CHANGED
@@ -2,9 +2,16 @@
2
2
 
3
3
  const { spawn, spawnSync } = require('child_process');
4
4
  const path = require('path');
5
+ const fs = require('fs');
6
+ const {
7
+ PLATFORMS,
8
+ getSettings,
9
+ platformState,
10
+ getSessions,
11
+ LGY_PM2_NAME,
12
+ LGY_SCRIPT_PATH,
13
+ } = require('./platforms');
5
14
 
6
- // Find the absolute path to the Python bot script
7
- const botPath = path.join(__dirname, '..', 'src', 'main.py');
8
15
  const { python: pythonExe, isWin } = require('../npm-scripts/venv-paths');
9
16
 
10
17
  const cmd = process.argv[2];
@@ -31,11 +38,7 @@ function runPm2(args, silent = true) {
31
38
  const result = spawnSync('npx', ['-y', 'pm2', ...args], {
32
39
  stdio: stdioOpt,
33
40
  cwd: path.join(__dirname, '..'),
34
- // pm2 pipes the Python process's stdout rather than giving it a
35
- // TTY, so Python defaults to block-buffering it - occasional
36
- // log lines (like a single WARNING) can sit in that buffer
37
- // indefinitely instead of reaching `pm2 logs`/bot.log. This
38
- // forces line-by-line flushing regardless of interpreter/OS.
41
+ // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
39
42
  env: { ...process.env, PYTHONUNBUFFERED: '1' },
40
43
  });
41
44
 
@@ -71,11 +74,7 @@ function runPm2(args, silent = true) {
71
74
  }
72
75
  }
73
76
 
74
- // Matches a leading timestamp in either format our logs actually use:
75
- // "2026-07-19 19:11:25 INFO ..." (loguru)
76
- // "[2026-07-19 14:31:53] [INFO ] ..." (aiohttp access log)
77
- // Only strips the FIRST bracket group if present, so aiohttp's second
78
- // "[INFO ]" bracket (not a timestamp) is left alone.
77
+ // Matches a leading timestamp from either loguru or aiohttp's access-log format; only strips the first bracket group so aiohttp's second "[INFO ]" bracket is left alone.
79
78
  const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
80
79
  // loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
81
80
  // eslint-disable-next-line no-control-regex
@@ -97,25 +96,22 @@ function colorizeLevel(line) {
97
96
  });
98
97
  }
99
98
 
100
- function runPm2LogsClean(args, showStamps = false) {
99
+ function runPm2LogsStream(args, printLine) {
101
100
  const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
102
101
 
103
- const printLine = (line) => {
104
- if (line.trim().length === 0) return;
105
- if (
106
- line.includes('In-memory PM2') ||
107
- line.includes('pm2 update') ||
108
- line.includes('[TAILING]') ||
109
- line.includes('.pm2/logs/lgy') ||
110
- line.includes('In memory PM2 version') ||
111
- line.includes('Local PM2 version') ||
112
- line.match(/^>+ /)
113
- ) {
114
- return;
115
- }
116
- const clean = line.replace(ANSI_ESCAPE, '');
117
- const displayLine = colorizeLevel(showStamps ? clean : clean.replace(TIMESTAMP_PREFIX, ''));
118
- console.log(displayLine);
102
+ const isNoise = (line) =>
103
+ line.trim().length === 0 ||
104
+ line.includes('In-memory PM2') ||
105
+ line.includes('pm2 update') ||
106
+ line.includes('[TAILING]') ||
107
+ line.includes('.pm2/logs/lgy') ||
108
+ line.includes('In memory PM2 version') ||
109
+ line.includes('Local PM2 version') ||
110
+ line.match(/^>+ /);
111
+
112
+ const handleLine = (line) => {
113
+ if (isNoise(line)) return;
114
+ printLine(line);
119
115
  };
120
116
 
121
117
  // A line can arrive split across two 'data' events, so buffer until '\n' is seen.
@@ -125,10 +121,10 @@ function runPm2LogsClean(args, showStamps = false) {
125
121
  buffer += data.toString();
126
122
  const lines = buffer.split('\n');
127
123
  buffer = lines.pop(); // last element: '' if buffer ended in '\n', else the incomplete tail
128
- for (const line of lines) printLine(line);
124
+ for (const line of lines) handleLine(line);
129
125
  };
130
126
  handler.flush = () => {
131
- if (buffer) printLine(buffer);
127
+ if (buffer) handleLine(buffer);
132
128
  buffer = '';
133
129
  };
134
130
  return handler;
@@ -144,53 +140,99 @@ function runPm2LogsClean(args, showStamps = false) {
144
140
  });
145
141
  }
146
142
 
147
- function verifyStartup() {
148
- process.stdout.write(
149
- `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
150
- );
151
-
152
- let cp = spawn('npx', ['-y', 'pm2', 'logs', 'lgy', '--raw', '--lines', '0'], {
153
- cwd: path.join(__dirname, '..'),
143
+ function runPm2LogsClean(args, showStamps = false) {
144
+ runPm2LogsStream(args, (line) => {
145
+ const clean = line.replace(ANSI_ESCAPE, '');
146
+ console.log(colorizeLevel(showStamps ? clean : clean.replace(TIMESTAMP_PREFIX, '')));
154
147
  });
148
+ }
155
149
 
156
- let timer = setTimeout(() => {
157
- console.log(
158
- `\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
150
+ function verifyStartup() {
151
+ return new Promise((resolve) => {
152
+ process.stdout.write(
153
+ `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
159
154
  );
160
- cp.kill();
161
- process.exit(1);
162
- }, 15000);
163
155
 
164
- const checkLog = (data) => {
165
- const str = data.toString();
166
- if (str.includes('Bot is fully online and ready!')) {
156
+ let cp = spawn('npx', ['-y', 'pm2', 'logs', LGY_PM2_NAME, '--raw', '--lines', '20'], {
157
+ cwd: path.join(__dirname, '..'),
158
+ });
159
+
160
+ let settled = false;
161
+ const finish = (ok) => {
162
+ if (settled) return;
163
+ settled = true;
167
164
  clearTimeout(timer);
165
+ cp.kill();
166
+ resolve(ok);
167
+ };
168
+
169
+ // Give the --lines 20 replay burst a moment to flush before treating error text as a fresh crash, not old log noise.
170
+ let errorDetectionArmed = false;
171
+ setTimeout(() => {
172
+ errorDetectionArmed = true;
173
+ }, 1500);
174
+
175
+ let timer = setTimeout(() => {
168
176
  console.log(
169
- `\n${color.green}✔${color.reset} Bot successfully came online and is connected to Discord!\n`,
177
+ `\n\n${color.yellow} Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
170
178
  );
171
- cp.kill();
172
- process.exit(0);
173
- } else if (
174
- str.includes('Traceback (most recent call last):') ||
175
- str.includes('Error:') ||
176
- str.includes('Exception:')
177
- ) {
178
- clearTimeout(timer);
179
- console.log(`\n\n${color.yellow}❌ Error detected during startup:${color.reset}`);
180
- const errorLines = str
181
- .split('\n')
182
- .filter(
183
- (l) =>
184
- !l.includes('In-memory') && !l.includes('[TAILING]') && l.trim().length > 0,
185
- );
186
- console.log(errorLines.join('\n'));
187
- cp.kill();
188
- process.exit(1);
189
- }
190
- };
179
+ finish(false);
180
+ }, 30000);
181
+
182
+ const checkLog = (data) => {
183
+ if (settled) return;
184
+ const str = data.toString();
185
+ if (str.includes('Bot is fully online and ready!')) {
186
+ console.log(`\n${color.green}✔${color.reset} Bot successfully came online!\n`);
187
+ finish(true);
188
+ } else if (
189
+ errorDetectionArmed &&
190
+ (str.includes('Traceback (most recent call last):') ||
191
+ str.includes('Error:') ||
192
+ str.includes('Exception:'))
193
+ ) {
194
+ console.log(`\n\n${color.yellow}❌ Error detected during startup:${color.reset}`);
195
+ const errorLines = str
196
+ .split('\n')
197
+ .filter(
198
+ (l) =>
199
+ !l.includes('In-memory') &&
200
+ !l.includes('[TAILING]') &&
201
+ l.trim().length > 0,
202
+ );
203
+ console.log(errorLines.join('\n'));
204
+ finish(false);
205
+ }
206
+ };
207
+
208
+ cp.stdout.on('data', checkLog);
209
+ cp.stderr.on('data', checkLog);
210
+ });
211
+ }
212
+
213
+ function formatUptime(pmUptimeMs) {
214
+ const seconds = Math.floor((Date.now() - pmUptimeMs) / 1000);
215
+ if (seconds < 60) return `${seconds}s`;
216
+ const minutes = Math.floor(seconds / 60);
217
+ if (minutes < 60) return `${minutes}m`;
218
+ const hours = Math.floor(minutes / 60);
219
+ if (hours < 24) return `${hours}h ${minutes % 60}m`;
220
+ const days = Math.floor(hours / 24);
221
+ return `${days}d ${hours % 24}h`;
222
+ }
191
223
 
192
- cp.stdout.on('data', checkLog);
193
- cp.stderr.on('data', checkLog);
224
+ function renderTable(headers, rows) {
225
+ const widths = headers.map((h, i) =>
226
+ Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
227
+ );
228
+ const pad = (s, w) => ` ${String(s).padEnd(w)} `;
229
+ const sepLine = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
230
+ const rowLine = (cells) => '│' + cells.map((c, i) => pad(c, widths[i])).join('│') + '│';
231
+
232
+ const lines = [sepLine('┌', '┬', '┐'), rowLine(headers), sepLine('├', '┼', '┤')];
233
+ for (const row of rows) lines.push(rowLine(row));
234
+ lines.push(sepLine('└', '┴', '┘'));
235
+ return lines.join('\n');
194
236
  }
195
237
 
196
238
  if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
@@ -198,16 +240,16 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
198
240
  console.log(`linkgravity v${pkg.version}`);
199
241
  } else if (cmd === 'start') {
200
242
  info('Starting LinkGravity daemon...');
201
- runPm2(['start', botPath, '--interpreter', pythonExe, '--name', 'lgy']);
202
- verifyStartup();
243
+ runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
244
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
203
245
  } else if (cmd === 'stop') {
204
246
  info('Stopping LinkGravity daemon...');
205
- runPm2(['stop', 'lgy']);
247
+ runPm2(['stop', LGY_PM2_NAME]);
206
248
  success('Daemon stopped successfully.\n');
207
249
  } else if (cmd === 'restart') {
208
250
  info('Restarting LinkGravity daemon...');
209
- runPm2(['restart', 'lgy', '--update-env']);
210
- verifyStartup();
251
+ runPm2(['restart', LGY_PM2_NAME, '--update-env']);
252
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
211
253
  } else if (cmd === 'logs') {
212
254
  const SHORT_FLAGS = ['-f', '-n', '-t'];
213
255
  let args = process.argv.slice(3).flatMap((arg) => {
@@ -217,7 +259,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
217
259
  if (!chars.every((c) => SHORT_FLAGS.includes(`-${c}`))) return [arg];
218
260
  return chars.map((c) => `-${c}`);
219
261
  });
220
- let pm2Args = ['logs', 'lgy'];
262
+ let pm2Args = ['logs', LGY_PM2_NAME];
221
263
  let isFollow = false;
222
264
  let showStamps = false;
223
265
 
@@ -244,6 +286,38 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
244
286
  }
245
287
  pm2Args.push('--raw');
246
288
  runPm2LogsClean(pm2Args, showStamps);
289
+ } else if (cmd === 'status') {
290
+ const settings = getSettings();
291
+ const sessions = getSessions();
292
+
293
+ let pm2Procs = [];
294
+ const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
295
+ if (jlist.status === 0) {
296
+ try {
297
+ pm2Procs = JSON.parse(jlist.stdout.toString());
298
+ } catch (e) {}
299
+ }
300
+
301
+ const proc = pm2Procs.find((p) => p.name === LGY_PM2_NAME);
302
+ if (!proc) {
303
+ console.log(`\n${color.cyan}▶${color.reset} daemon: not running\n`);
304
+ } else {
305
+ const mem = proc.monit ? `${Math.round(proc.monit.memory / 1024 / 1024)}mb` : '?';
306
+ const cpu = proc.monit ? `${proc.monit.cpu}%` : '?';
307
+ const uptime =
308
+ proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
309
+ console.log(
310
+ `\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status} (uptime: ${uptime}, restarts: ${proc.pm2_env.restart_time}, cpu: ${cpu}, mem: ${mem})\n`,
311
+ );
312
+ }
313
+
314
+ const rows = Object.entries(PLATFORMS).map(([key, def]) => {
315
+ const { enabled } = platformState(key, settings);
316
+ const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
317
+ return [def.label, enabled ? 'yes' : 'no', String(sessionCount)];
318
+ });
319
+ console.log(renderTable(['platform', 'enabled', 'sessions'], rows));
320
+ console.log();
247
321
  } else if (cmd === 'enable') {
248
322
  if (isWin) {
249
323
  console.log(
@@ -304,19 +378,19 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
304
378
  success(`Installed v${latestVersion}.`);
305
379
 
306
380
  info('Restarting daemon to apply the update...');
307
- const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', 'lgy', '--update-env'], {
381
+ const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', LGY_PM2_NAME, '--update-env'], {
308
382
  stdio: 'pipe',
309
383
  cwd: path.join(__dirname, '..'),
310
384
  env: { ...process.env, PYTHONUNBUFFERED: '1' },
311
385
  });
312
386
 
313
387
  if (restartResult.status === 0) {
314
- verifyStartup();
388
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
315
389
  } else if ((restartResult.stderr || '').toString().includes('not found')) {
316
390
  // Wasn't running before the update - start fresh instead of a false "restarted".
317
391
  info("Daemon wasn't running - starting it fresh...");
318
- runPm2(['start', botPath, '--interpreter', pythonExe, '--name', 'lgy']);
319
- verifyStartup();
392
+ runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
393
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
320
394
  } else {
321
395
  console.error((restartResult.stderr || '').toString().trim());
322
396
  console.error(
@@ -338,6 +412,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
338
412
  ' stop Stop the background bot',
339
413
  ' restart Restart the background bot',
340
414
  ' logs View bot logs (Options: --tail, -n, -f, -t/--timestamp)',
415
+ ' status Show daemon status and per-platform enabled/session counts',
341
416
  ' enable Register bot to start automatically on system boot',
342
417
  ' disable Remove bot from system boot',
343
418
  ' setup Run the configuration wizard (init)',
@@ -359,6 +434,11 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
359
434
  { label: 'Stop', value: 'stop', hint: 'Stop the running daemon' },
360
435
  { label: 'Restart', value: 'restart', hint: 'Restart the running daemon' },
361
436
  { label: 'Logs', value: 'logs', hint: 'View the live console logs' },
437
+ {
438
+ label: 'Status',
439
+ value: 'status',
440
+ hint: 'Show daemon status and per-platform sessions',
441
+ },
362
442
  { label: 'Setup', value: 'setup', hint: 'Configure bot tokens and settings' },
363
443
  {
364
444
  label: 'Update',
@@ -0,0 +1,63 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const workspaceDir = path.join(os.homedir(), '.gemini', 'linkgravity');
6
+ const settingsPath = path.join(workspaceDir, 'lgy.json');
7
+ const sessionsPath = path.join(workspaceDir, 'data', 'sessions.json');
8
+
9
+ if (!fs.existsSync(workspaceDir)) fs.mkdirSync(workspaceDir, { recursive: true });
10
+
11
+ function getSettings() {
12
+ if (fs.existsSync(settingsPath)) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
15
+ } catch (e) {}
16
+ }
17
+ return {};
18
+ }
19
+
20
+ function updateSettings(updates) {
21
+ const settings = getSettings();
22
+ for (const [key, value] of Object.entries(updates)) {
23
+ settings[key] = value;
24
+ }
25
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
26
+ }
27
+
28
+ function getSessions() {
29
+ if (fs.existsSync(sessionsPath)) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(sessionsPath, 'utf8'));
32
+ } catch (e) {}
33
+ }
34
+ return {};
35
+ }
36
+
37
+ // Both platforms now run in one shared pm2 process - main.py checks discord_enabled/telegram_enabled at startup.
38
+ const LGY_PM2_NAME = 'lgy';
39
+ const LGY_SCRIPT_PATH = path.join(__dirname, '..', 'src', 'main.py');
40
+
41
+ const PLATFORMS = {
42
+ discord: { label: 'Discord' },
43
+ telegram: { label: 'Telegram' },
44
+ };
45
+
46
+ function platformState(key, settings) {
47
+ const configured = !!settings[`${key}_token`];
48
+ const enabled = settings[`${key}_enabled`] ?? (key === 'discord' && configured);
49
+ return { configured, enabled };
50
+ }
51
+
52
+ module.exports = {
53
+ workspaceDir,
54
+ settingsPath,
55
+ sessionsPath,
56
+ getSettings,
57
+ updateSettings,
58
+ getSessions,
59
+ LGY_PM2_NAME,
60
+ LGY_SCRIPT_PATH,
61
+ PLATFORMS,
62
+ platformState,
63
+ };