omnikey-cli 1.5.3 → 1.5.5
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 +64 -0
- package/dist/index.js +50 -1
- package/dist/telegramClient.js +188 -0
- package/dist/telegramDaemon.js +432 -0
- package/package.json +12 -6
- package/src/index.ts +66 -1
- package/src/telegramClient.ts +227 -0
- package/src/telegramDaemon.ts +453 -0
- package/telegram-client-dist/agentClient.js +384 -0
- package/telegram-client-dist/config.js +67 -0
- package/telegram-client-dist/db.js +78 -0
- package/telegram-client-dist/index.js +97 -0
- package/telegram-client-dist/notifyTelegram.js +901 -0
- package/telegram-client-dist/omnikeyAuth.js +53 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
4
|
+
import { request as httpsRequest } from 'https';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { getConfigDir, getConfigPath, readConfig, initLogFiles } from './utils';
|
|
7
|
+
|
|
8
|
+
const REQUIRED_ENV_KEYS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'] as const;
|
|
9
|
+
type RequiredKey = (typeof REQUIRED_ENV_KEYS)[number];
|
|
10
|
+
|
|
11
|
+
interface PromptOptions {
|
|
12
|
+
/** When true, never prompt — throw if anything is missing. */
|
|
13
|
+
nonInteractive?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where the bundled bot lives at runtime. The CLI's `build:telegram-client`
|
|
18
|
+
* script populates this directory with the bot's compiled output plus its
|
|
19
|
+
* production `node_modules`.
|
|
20
|
+
*/
|
|
21
|
+
function resolveBundleRoot(): string {
|
|
22
|
+
// dist/telegramClient.js → ../telegram-client-dist
|
|
23
|
+
return path.resolve(__dirname, '..', 'telegram-client-dist');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveBundledEntry(): string {
|
|
27
|
+
return path.join(resolveBundleRoot(), 'dist', 'index.js');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function persistConfig(values: Record<string, string>): void {
|
|
31
|
+
const configDir = getConfigDir();
|
|
32
|
+
const configPath = getConfigPath();
|
|
33
|
+
const existing = readConfig();
|
|
34
|
+
const merged = { ...existing, ...values };
|
|
35
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2), 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify a Telegram bot token via the Bot API's getMe endpoint.
|
|
41
|
+
* Resolves to the bot's @username on success, throws on failure.
|
|
42
|
+
*/
|
|
43
|
+
function verifyToken(token: string): Promise<string> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const req = httpsRequest(
|
|
46
|
+
{
|
|
47
|
+
method: 'GET',
|
|
48
|
+
hostname: 'api.telegram.org',
|
|
49
|
+
path: `/bot${token}/getMe`,
|
|
50
|
+
},
|
|
51
|
+
(res) => {
|
|
52
|
+
let body = '';
|
|
53
|
+
res.on('data', (chunk) => (body += chunk));
|
|
54
|
+
res.on('end', () => {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(body) as {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
description?: string;
|
|
59
|
+
result?: { username?: string };
|
|
60
|
+
};
|
|
61
|
+
if (!parsed.ok) {
|
|
62
|
+
reject(new Error(parsed.description || 'Telegram rejected the token'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
resolve(parsed.result?.username || 'bot');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
reject(new Error(`Invalid JSON from Telegram: ${body}`));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
req.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ensure TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are present in
|
|
79
|
+
* ~/.omnikey/config.json. Prompts for any missing values (unless
|
|
80
|
+
* `nonInteractive` is set) and validates the token against the Bot API.
|
|
81
|
+
*
|
|
82
|
+
* Returns the resolved config values (existing or newly captured).
|
|
83
|
+
*/
|
|
84
|
+
export async function ensureTelegramConfig(
|
|
85
|
+
options: PromptOptions = {},
|
|
86
|
+
): Promise<Record<RequiredKey, string>> {
|
|
87
|
+
const existing = readConfig();
|
|
88
|
+
const resolved: Partial<Record<RequiredKey, string>> = {};
|
|
89
|
+
const missing: RequiredKey[] = [];
|
|
90
|
+
|
|
91
|
+
for (const key of REQUIRED_ENV_KEYS) {
|
|
92
|
+
const value = existing[key];
|
|
93
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
94
|
+
resolved[key] = value.trim();
|
|
95
|
+
} else {
|
|
96
|
+
missing.push(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (missing.length === 0) {
|
|
101
|
+
return resolved as Record<RequiredKey, string>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.nonInteractive) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Missing required Telegram config in ${getConfigPath()}: ${missing.join(', ')}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('\nTelegram client configuration required.');
|
|
111
|
+
console.log(
|
|
112
|
+
'See telegram/README.md for how to create a bot with @BotFather and find your chat id.\n',
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const toPersist: Record<string, string> = {};
|
|
116
|
+
|
|
117
|
+
if (missing.includes('TELEGRAM_BOT_TOKEN')) {
|
|
118
|
+
const { token } = await inquirer.prompt<{ token: string }>([
|
|
119
|
+
{
|
|
120
|
+
type: 'password',
|
|
121
|
+
name: 'token',
|
|
122
|
+
mask: '*',
|
|
123
|
+
message: 'Enter your Telegram bot token (from @BotFather):',
|
|
124
|
+
validate: (input: string) =>
|
|
125
|
+
/^\d+:[A-Za-z0-9_-]{20,}$/.test(input.trim()) ||
|
|
126
|
+
'Token should look like 123456789:ABC... (digits, colon, 20+ chars)',
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
try {
|
|
130
|
+
const username = await verifyToken(token.trim());
|
|
131
|
+
console.log(`Token validated for @${username}.`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Telegram rejected the token: ${err instanceof Error ? err.message : String(err)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
resolved.TELEGRAM_BOT_TOKEN = token.trim();
|
|
138
|
+
toPersist.TELEGRAM_BOT_TOKEN = token.trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (missing.includes('TELEGRAM_CHAT_ID')) {
|
|
142
|
+
const { chatId } = await inquirer.prompt<{ chatId: string }>([
|
|
143
|
+
{
|
|
144
|
+
type: 'input',
|
|
145
|
+
name: 'chatId',
|
|
146
|
+
message:
|
|
147
|
+
'Enter the chat id to receive notifications (curl https://api.telegram.org/bot<token>/getUpdates):',
|
|
148
|
+
validate: (input: string) =>
|
|
149
|
+
/^-?\d+$/.test(input.trim()) || 'Chat id must be a numeric value (groups are negative)',
|
|
150
|
+
},
|
|
151
|
+
]);
|
|
152
|
+
resolved.TELEGRAM_CHAT_ID = chatId.trim();
|
|
153
|
+
toPersist.TELEGRAM_CHAT_ID = chatId.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (Object.keys(toPersist).length > 0) {
|
|
157
|
+
persistConfig(toPersist);
|
|
158
|
+
console.log(`Saved Telegram config to ${getConfigPath()}.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return resolved as Record<RequiredKey, string>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Spawn the bundled telegram-bot server as a long-lived child process.
|
|
166
|
+
* The bot reads PORT from process.env (defaults to 7072 in the app), so we
|
|
167
|
+
* inject the CLI's chosen port that way to keep the upstream code untouched.
|
|
168
|
+
*/
|
|
169
|
+
export function spawnTelegramClient(port: number, env: Record<string, string>): ChildProcess {
|
|
170
|
+
const bundleRoot = resolveBundleRoot();
|
|
171
|
+
const entry = resolveBundledEntry();
|
|
172
|
+
if (!fs.existsSync(entry)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Bundled telegram-client not found at ${entry}. ` +
|
|
175
|
+
'Reinstall omnikey-cli or run `npm run build` from the cli/ directory.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const configDir = getConfigDir();
|
|
180
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
181
|
+
const logPath = path.join(configDir, 'telegram-client.log');
|
|
182
|
+
const errorLogPath = path.join(configDir, 'telegram-client-error.log');
|
|
183
|
+
const { out, err } = initLogFiles(logPath, errorLogPath);
|
|
184
|
+
|
|
185
|
+
const child = spawn(process.execPath, [entry], {
|
|
186
|
+
detached: false,
|
|
187
|
+
stdio: ['ignore', out, err],
|
|
188
|
+
// cwd = bundle root so the bot's dotenv.config() and relative paths line up
|
|
189
|
+
cwd: bundleRoot,
|
|
190
|
+
env: {
|
|
191
|
+
...process.env,
|
|
192
|
+
...env,
|
|
193
|
+
PORT: String(port),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
child.on('exit', (code, signal) => {
|
|
198
|
+
fs.closeSync(out);
|
|
199
|
+
fs.closeSync(err);
|
|
200
|
+
if (code !== 0) {
|
|
201
|
+
console.error(`telegram-client exited with code=${code} signal=${signal ?? 'none'}`);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return child;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Top-level command: `omnikey telegram-client [--port <port>]`. */
|
|
209
|
+
export async function startTelegramClientCommand(port: number): Promise<void> {
|
|
210
|
+
const cfg = await ensureTelegramConfig();
|
|
211
|
+
const child = spawnTelegramClient(port, cfg);
|
|
212
|
+
console.log(
|
|
213
|
+
`telegram-client started (pid=${child.pid}) on port ${port}. ` +
|
|
214
|
+
`Logs: ${path.join(getConfigDir(), 'telegram-client.log')}`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Keep the CLI process alive until the child exits so users can Ctrl+C.
|
|
218
|
+
await new Promise<void>((resolve) => {
|
|
219
|
+
child.on('exit', () => resolve());
|
|
220
|
+
process.on('SIGINT', () => {
|
|
221
|
+
child.kill('SIGINT');
|
|
222
|
+
});
|
|
223
|
+
process.on('SIGTERM', () => {
|
|
224
|
+
child.kill('SIGTERM');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync, execFileSync, spawnSync } from 'child_process';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { isWindows, getHomeDir, getConfigDir, initLogFiles } from './utils';
|
|
7
|
+
import { ensureTelegramConfig } from './telegramClient';
|
|
8
|
+
|
|
9
|
+
const LABEL = `com.${os.userInfo().username}.telegram`;
|
|
10
|
+
const PLIST_NAME = `${LABEL}.plist`;
|
|
11
|
+
const WINDOWS_SERVICE_NAME = 'OmnikeyTelegram';
|
|
12
|
+
|
|
13
|
+
// At runtime __dirname is cli/dist/. The bundled telegram app is copied into
|
|
14
|
+
// cli/telegram-client-dist/ by the build:telegram-client script, so one level
|
|
15
|
+
// up from dist/ lands at the package root, then into the bundle directory.
|
|
16
|
+
// This matches resolveBundleRoot() in telegramClient.ts and works correctly
|
|
17
|
+
// both in the monorepo and after `npm install -g omnikey-cli`.
|
|
18
|
+
const TELEGRAM_BOT_ROOT = path.resolve(__dirname, '..', 'telegram-client-dist');
|
|
19
|
+
const ENTRY_POINT = path.join(TELEGRAM_BOT_ROOT, 'dist', 'index.js');
|
|
20
|
+
|
|
21
|
+
const HOME = getHomeDir();
|
|
22
|
+
|
|
23
|
+
// macOS — launchd LaunchAgent paths
|
|
24
|
+
const LAUNCH_AGENTS_DIR = path.join(HOME, 'Library', 'LaunchAgents');
|
|
25
|
+
const PLIST_PATH = path.join(LAUNCH_AGENTS_DIR, PLIST_NAME);
|
|
26
|
+
const MAC_LOG_DIR = path.join(HOME, 'Library', 'Logs', 'telegram');
|
|
27
|
+
const MAC_STDOUT_LOG = path.join(MAC_LOG_DIR, 'out.log');
|
|
28
|
+
const MAC_STDERR_LOG = path.join(MAC_LOG_DIR, 'err.log');
|
|
29
|
+
|
|
30
|
+
// Windows — store logs alongside the rest of the CLI config
|
|
31
|
+
const WIN_CONFIG_DIR = path.join(getConfigDir(), 'telegram');
|
|
32
|
+
const WIN_LOG_PATH = path.join(WIN_CONFIG_DIR, 'daemon.log');
|
|
33
|
+
const WIN_ERROR_LOG_PATH = path.join(WIN_CONFIG_DIR, 'daemon-error.log');
|
|
34
|
+
|
|
35
|
+
const FORWARD_ENV_KEYS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID', 'PORT', 'LOG_LEVEL'];
|
|
36
|
+
|
|
37
|
+
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function ensureBuilt(): void {
|
|
40
|
+
if (fs.existsSync(ENTRY_POINT)) return;
|
|
41
|
+
console.error(
|
|
42
|
+
`Bundled telegram entry point not found at ${ENTRY_POINT}.\n` +
|
|
43
|
+
'Reinstall omnikey-cli to restore the bundle: npm install -g omnikey-cli',
|
|
44
|
+
);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function escapeXml(s: string): string {
|
|
49
|
+
return s
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── macOS (launchd) ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function buildPlist(): string {
|
|
59
|
+
const envEntries: string[] = [
|
|
60
|
+
`<key>PATH</key><string>${escapeXml(process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin')}</string>`,
|
|
61
|
+
`<key>HOME</key><string>${escapeXml(HOME)}</string>`,
|
|
62
|
+
];
|
|
63
|
+
for (const key of FORWARD_ENV_KEYS) {
|
|
64
|
+
const value = process.env[key];
|
|
65
|
+
if (value) envEntries.push(`<key>${key}</key><string>${escapeXml(value)}</string>`);
|
|
66
|
+
}
|
|
67
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
68
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
69
|
+
<plist version="1.0">
|
|
70
|
+
<dict>
|
|
71
|
+
<key>Label</key>
|
|
72
|
+
<string>${escapeXml(LABEL)}</string>
|
|
73
|
+
<key>ProgramArguments</key>
|
|
74
|
+
<array>
|
|
75
|
+
<string>${escapeXml(process.execPath)}</string>
|
|
76
|
+
<string>${escapeXml(ENTRY_POINT)}</string>
|
|
77
|
+
</array>
|
|
78
|
+
<key>WorkingDirectory</key>
|
|
79
|
+
<string>${escapeXml(TELEGRAM_BOT_ROOT)}</string>
|
|
80
|
+
<key>EnvironmentVariables</key>
|
|
81
|
+
<dict>
|
|
82
|
+
${envEntries.join('\n ')}
|
|
83
|
+
</dict>
|
|
84
|
+
<key>RunAtLoad</key>
|
|
85
|
+
<true/>
|
|
86
|
+
<key>KeepAlive</key>
|
|
87
|
+
<true/>
|
|
88
|
+
<key>ThrottleInterval</key>
|
|
89
|
+
<integer>10</integer>
|
|
90
|
+
<key>StandardOutPath</key>
|
|
91
|
+
<string>${escapeXml(MAC_STDOUT_LOG)}</string>
|
|
92
|
+
<key>StandardErrorPath</key>
|
|
93
|
+
<string>${escapeXml(MAC_STDERR_LOG)}</string>
|
|
94
|
+
<key>ProcessType</key>
|
|
95
|
+
<string>Background</string>
|
|
96
|
+
</dict>
|
|
97
|
+
</plist>
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function unloadIfLoaded(): void {
|
|
102
|
+
if (!fs.existsSync(PLIST_PATH)) return;
|
|
103
|
+
try {
|
|
104
|
+
execSync(`launchctl unload "${PLIST_PATH}"`, { stdio: 'pipe' });
|
|
105
|
+
} catch {
|
|
106
|
+
/* not loaded — fine */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function startMacOS(): void {
|
|
111
|
+
fs.mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
|
|
112
|
+
fs.mkdirSync(MAC_LOG_DIR, { recursive: true });
|
|
113
|
+
for (const f of [MAC_STDOUT_LOG, MAC_STDERR_LOG]) {
|
|
114
|
+
if (!fs.existsSync(f)) fs.writeFileSync(f, '');
|
|
115
|
+
}
|
|
116
|
+
fs.writeFileSync(PLIST_PATH, buildPlist(), 'utf-8');
|
|
117
|
+
console.log(`Wrote LaunchAgent: ${PLIST_PATH}`);
|
|
118
|
+
unloadIfLoaded();
|
|
119
|
+
try {
|
|
120
|
+
execSync(`launchctl load "${PLIST_PATH}"`, { stdio: 'inherit' });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('launchctl load failed:', (e as Error).message);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
console.log(`Loaded ${LABEL}. The service will run at login and on reboot.`);
|
|
126
|
+
console.log(`stdout: ${MAC_STDOUT_LOG}`);
|
|
127
|
+
console.log(`stderr: ${MAC_STDERR_LOG}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function stopMacOS(): void {
|
|
131
|
+
if (!fs.existsSync(PLIST_PATH)) {
|
|
132
|
+
console.log(`No LaunchAgent at ${PLIST_PATH}. Nothing to stop.`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
unloadIfLoaded();
|
|
136
|
+
console.log(`Unloaded ${LABEL}.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusMacOS(): void {
|
|
140
|
+
if (!fs.existsSync(PLIST_PATH)) {
|
|
141
|
+
console.log(`Not installed (${PLIST_PATH} missing).`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(`Plist: ${PLIST_PATH}`);
|
|
145
|
+
try {
|
|
146
|
+
const out = execSync(`launchctl list | grep ${LABEL} || true`).toString();
|
|
147
|
+
if (out.trim()) {
|
|
148
|
+
console.log('launchctl list:');
|
|
149
|
+
console.log(out.trim());
|
|
150
|
+
} else {
|
|
151
|
+
console.log('Not currently loaded by launchd.');
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.warn('launchctl list failed:', (e as Error).message);
|
|
155
|
+
}
|
|
156
|
+
const port = process.env.PORT || '6666';
|
|
157
|
+
try {
|
|
158
|
+
const lsof = execSync(`lsof -i :${port} -sTCP:LISTEN -t || true`).toString().trim();
|
|
159
|
+
console.log(
|
|
160
|
+
lsof
|
|
161
|
+
? `Listening on port ${port} (pid ${lsof.split('\n')[0]}).`
|
|
162
|
+
: `Nothing listening on port ${port}.`,
|
|
163
|
+
);
|
|
164
|
+
} catch {
|
|
165
|
+
/* ignore */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function logsMacOS(): void {
|
|
170
|
+
if (!fs.existsSync(MAC_STDOUT_LOG) && !fs.existsSync(MAC_STDERR_LOG)) {
|
|
171
|
+
console.log('No log files yet. Start the daemon first.');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
console.log(`Tailing ${MAC_STDOUT_LOG} and ${MAC_STDERR_LOG}. Ctrl-C to stop.`);
|
|
175
|
+
const child = spawnSync('tail', ['-n', '100', '-F', MAC_STDOUT_LOG, MAC_STDERR_LOG], {
|
|
176
|
+
stdio: 'inherit',
|
|
177
|
+
});
|
|
178
|
+
process.exit(child.status ?? 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function uninstallMacOS(): void {
|
|
182
|
+
unloadIfLoaded();
|
|
183
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
184
|
+
fs.rmSync(PLIST_PATH);
|
|
185
|
+
console.log(`Removed ${PLIST_PATH}.`);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(`No plist at ${PLIST_PATH}.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Windows (NSSM) ──────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function resolveNssm(): string | null {
|
|
194
|
+
try {
|
|
195
|
+
return execSync('where nssm', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function startWindows(): Promise<void> {
|
|
202
|
+
let nssmPath = resolveNssm();
|
|
203
|
+
|
|
204
|
+
if (!nssmPath) {
|
|
205
|
+
const { install } = await inquirer.prompt<{ install: boolean }>([
|
|
206
|
+
{
|
|
207
|
+
type: 'confirm',
|
|
208
|
+
name: 'install',
|
|
209
|
+
message: 'NSSM is required but not found. Install it now via winget?',
|
|
210
|
+
default: true,
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
if (!install) {
|
|
215
|
+
console.log(
|
|
216
|
+
'Aborted. Install NSSM manually and re-run in an elevated (Administrator) terminal.',
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log('Installing NSSM via winget...');
|
|
222
|
+
try {
|
|
223
|
+
execSync('winget install nssm --accept-package-agreements --accept-source-agreements', {
|
|
224
|
+
stdio: 'inherit',
|
|
225
|
+
});
|
|
226
|
+
} catch (e) {
|
|
227
|
+
console.error('winget install failed:', (e as any)?.message ?? e);
|
|
228
|
+
console.log('Try manually: scoop install nssm or choco install nssm');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// winget updates the registry PATH; spawn a new cmd session to pick it up.
|
|
233
|
+
try {
|
|
234
|
+
nssmPath = execSync('cmd /c where nssm', { stdio: 'pipe' })
|
|
235
|
+
.toString()
|
|
236
|
+
.trim()
|
|
237
|
+
.split('\n')[0]
|
|
238
|
+
.trim();
|
|
239
|
+
} catch {
|
|
240
|
+
nssmPath = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!nssmPath) {
|
|
244
|
+
console.log('NSSM installed successfully.');
|
|
245
|
+
console.log('Please open a new elevated (Administrator) terminal and re-run this command.');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fs.mkdirSync(WIN_CONFIG_DIR, { recursive: true });
|
|
251
|
+
initLogFiles(WIN_LOG_PATH, WIN_ERROR_LOG_PATH);
|
|
252
|
+
|
|
253
|
+
// Remove any pre-existing service so a fresh install is idempotent.
|
|
254
|
+
try {
|
|
255
|
+
execFileSync(nssmPath, ['stop', WINDOWS_SERVICE_NAME], { stdio: 'pipe' });
|
|
256
|
+
} catch {
|
|
257
|
+
/* not running */
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
execFileSync(nssmPath, ['remove', WINDOWS_SERVICE_NAME, 'confirm'], { stdio: 'pipe' });
|
|
261
|
+
} catch {
|
|
262
|
+
/* didn't exist */
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// NSSM services run as LocalSystem; forward the user home so the bot's
|
|
266
|
+
// dotenv / config resolution works correctly.
|
|
267
|
+
const env: Record<string, string> = { USERPROFILE: HOME, HOME };
|
|
268
|
+
for (const key of FORWARD_ENV_KEYS) {
|
|
269
|
+
const value = process.env[key];
|
|
270
|
+
if (value) env[key] = value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
execFileSync(nssmPath, ['install', WINDOWS_SERVICE_NAME, process.execPath, ENTRY_POINT], {
|
|
275
|
+
stdio: 'pipe',
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppDirectory', TELEGRAM_BOT_ROOT], {
|
|
279
|
+
stdio: 'pipe',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const envEntries = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
283
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppEnvironmentExtra', ...envEntries], {
|
|
284
|
+
stdio: 'pipe',
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppStdout', WIN_LOG_PATH], {
|
|
288
|
+
stdio: 'pipe',
|
|
289
|
+
});
|
|
290
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppStderr', WIN_ERROR_LOG_PATH], {
|
|
291
|
+
stdio: 'pipe',
|
|
292
|
+
});
|
|
293
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppRotateFiles', '1'], { stdio: 'pipe' });
|
|
294
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppExit', 'Default', 'Restart'], {
|
|
295
|
+
stdio: 'pipe',
|
|
296
|
+
});
|
|
297
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'AppRestartDelay', '3000'], {
|
|
298
|
+
stdio: 'pipe',
|
|
299
|
+
});
|
|
300
|
+
execFileSync(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'Start', 'SERVICE_AUTO_START'], {
|
|
301
|
+
stdio: 'pipe',
|
|
302
|
+
});
|
|
303
|
+
execFileSync(
|
|
304
|
+
nssmPath,
|
|
305
|
+
['set', WINDOWS_SERVICE_NAME, 'DisplayName', 'Omnikey Telegram'],
|
|
306
|
+
{ stdio: 'pipe' },
|
|
307
|
+
);
|
|
308
|
+
execFileSync(
|
|
309
|
+
nssmPath,
|
|
310
|
+
['set', WINDOWS_SERVICE_NAME, 'Description', 'Omnikey Telegram Daemon'],
|
|
311
|
+
{ stdio: 'pipe' },
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
execFileSync(nssmPath, ['start', WINDOWS_SERVICE_NAME], { stdio: 'pipe' });
|
|
315
|
+
|
|
316
|
+
console.log(`NSSM service installed and started: ${WINDOWS_SERVICE_NAME}`);
|
|
317
|
+
console.log('Telegram bot daemon runs on boot, auto-restarts on crash.');
|
|
318
|
+
console.log(`Logs: ${WIN_LOG_PATH}`);
|
|
319
|
+
console.log(` ${WIN_ERROR_LOG_PATH}`);
|
|
320
|
+
} catch (e: any) {
|
|
321
|
+
const msg: string = e?.stderr?.toString() || e?.message || String(e);
|
|
322
|
+
if (msg.toLowerCase().includes('access') || msg.toLowerCase().includes('privilege')) {
|
|
323
|
+
console.error('Failed to install NSSM service: administrator privileges are required.');
|
|
324
|
+
console.error('Re-run this command in an elevated (Administrator) terminal.');
|
|
325
|
+
} else {
|
|
326
|
+
console.error('Failed to install NSSM service:', msg);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function stopWindows(): void {
|
|
332
|
+
const nssmPath = resolveNssm();
|
|
333
|
+
if (!nssmPath) {
|
|
334
|
+
console.log('NSSM not found. Cannot stop service.');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
execFileSync(nssmPath, ['stop', WINDOWS_SERVICE_NAME], { stdio: 'inherit' });
|
|
339
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME} stopped.`);
|
|
340
|
+
} catch {
|
|
341
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME} was not running.`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function statusWindows(): void {
|
|
346
|
+
const nssmPath = resolveNssm();
|
|
347
|
+
if (!nssmPath) {
|
|
348
|
+
console.log('NSSM not found. Service status unknown.');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const out = execSync(`"${nssmPath}" status ${WINDOWS_SERVICE_NAME}`, { stdio: 'pipe' })
|
|
353
|
+
.toString()
|
|
354
|
+
.trim();
|
|
355
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME}: ${out}`);
|
|
356
|
+
} catch {
|
|
357
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME} is not installed.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function logsWindows(): void {
|
|
362
|
+
for (const [label, file] of [
|
|
363
|
+
['stdout', WIN_LOG_PATH],
|
|
364
|
+
['stderr', WIN_ERROR_LOG_PATH],
|
|
365
|
+
] as const) {
|
|
366
|
+
if (fs.existsSync(file)) {
|
|
367
|
+
console.log(`\n── ${label} (${file}) ──`);
|
|
368
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n').slice(-100).join('\n');
|
|
369
|
+
console.log(lines || '(empty)');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!fs.existsSync(WIN_LOG_PATH) && !fs.existsSync(WIN_ERROR_LOG_PATH)) {
|
|
373
|
+
console.log('No log files yet. Start the daemon first.');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function uninstallWindows(): void {
|
|
378
|
+
const nssmPath = resolveNssm();
|
|
379
|
+
if (!nssmPath) {
|
|
380
|
+
console.log('NSSM not found. Nothing to uninstall.');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
execFileSync(nssmPath, ['stop', WINDOWS_SERVICE_NAME], { stdio: 'pipe' });
|
|
385
|
+
} catch {
|
|
386
|
+
/* ok */
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
execFileSync(nssmPath, ['remove', WINDOWS_SERVICE_NAME, 'confirm'], { stdio: 'pipe' });
|
|
390
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME} removed.`);
|
|
391
|
+
} catch {
|
|
392
|
+
console.log(`Service ${WINDOWS_SERVICE_NAME} was not installed.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── Public API (consumed by cli/src/index.ts) ───────────────────────────────
|
|
397
|
+
|
|
398
|
+
export async function startTelegramDaemon(): Promise<void> {
|
|
399
|
+
// Prompt for TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID if not already saved,
|
|
400
|
+
// and persist them before handing off to the OS service manager so the
|
|
401
|
+
// daemon process inherits the correct credentials.
|
|
402
|
+
const cfg = await ensureTelegramConfig();
|
|
403
|
+
// Inject resolved credentials into process.env so buildPlist() / Windows
|
|
404
|
+
// env forwarding picks them up (they come from config.json, not the shell).
|
|
405
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
406
|
+
process.env[key] = value;
|
|
407
|
+
}
|
|
408
|
+
// Ensure PORT has a value so the plist always includes it.
|
|
409
|
+
process.env.PORT = process.env.PORT ?? '6666';
|
|
410
|
+
ensureBuilt();
|
|
411
|
+
if (isWindows) {
|
|
412
|
+
await startWindows();
|
|
413
|
+
} else {
|
|
414
|
+
startMacOS();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function stopTelegramDaemon(): void {
|
|
419
|
+
if (isWindows) {
|
|
420
|
+
stopWindows();
|
|
421
|
+
} else {
|
|
422
|
+
stopMacOS();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function restartTelegramDaemon(): Promise<void> {
|
|
427
|
+
stopTelegramDaemon();
|
|
428
|
+
await startTelegramDaemon();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function statusTelegramDaemon(): void {
|
|
432
|
+
if (isWindows) {
|
|
433
|
+
statusWindows();
|
|
434
|
+
} else {
|
|
435
|
+
statusMacOS();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function logsTelegramDaemon(): void {
|
|
440
|
+
if (isWindows) {
|
|
441
|
+
logsWindows();
|
|
442
|
+
} else {
|
|
443
|
+
logsMacOS();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function uninstallTelegramDaemon(): void {
|
|
448
|
+
if (isWindows) {
|
|
449
|
+
uninstallWindows();
|
|
450
|
+
} else {
|
|
451
|
+
uninstallMacOS();
|
|
452
|
+
}
|
|
453
|
+
}
|