telegram-ask 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +143 -0
  3. package/dist/approval/constants.js +10 -0
  4. package/dist/approval/formatter.js +27 -0
  5. package/dist/approval/index.js +5 -0
  6. package/dist/approval/parser.js +43 -0
  7. package/dist/approval/pending.js +47 -0
  8. package/dist/approval/service.js +65 -0
  9. package/dist/approval/types.js +1 -0
  10. package/dist/cli.js +161 -0
  11. package/dist/commands/constants.js +14 -0
  12. package/dist/commands/index.js +8 -0
  13. package/dist/commands/init.js +159 -0
  14. package/dist/commands/owner.js +18 -0
  15. package/dist/commands/pairing-wait.js +65 -0
  16. package/dist/commands/prompts.js +55 -0
  17. package/dist/commands/reset-owner.js +58 -0
  18. package/dist/commands/status.js +42 -0
  19. package/dist/commands/test.js +22 -0
  20. package/dist/commands/types.js +1 -0
  21. package/dist/commands/uninstall.js +30 -0
  22. package/dist/config/constants.js +13 -0
  23. package/dist/config/index.js +2 -0
  24. package/dist/config/schema.js +7 -0
  25. package/dist/config/store.js +104 -0
  26. package/dist/config/types.js +1 -0
  27. package/dist/daemon/index.js +1 -0
  28. package/dist/daemon/service.js +82 -0
  29. package/dist/install/compose.js +123 -0
  30. package/dist/install/constants.js +28 -0
  31. package/dist/install/envfile.js +50 -0
  32. package/dist/install/index.js +9 -0
  33. package/dist/install/paths.js +23 -0
  34. package/dist/install/quadlet.js +167 -0
  35. package/dist/install/registry.js +98 -0
  36. package/dist/install/schema.js +10 -0
  37. package/dist/install/state.js +24 -0
  38. package/dist/install/systemd.js +151 -0
  39. package/dist/install/templates.js +87 -0
  40. package/dist/install/types.js +1 -0
  41. package/dist/server/constants.js +2 -0
  42. package/dist/server/index.js +2 -0
  43. package/dist/server/schema.js +6 -0
  44. package/dist/server/server.js +74 -0
  45. package/dist/server/types.js +1 -0
  46. package/dist/system/constants.js +6 -0
  47. package/dist/system/exec.js +30 -0
  48. package/dist/system/files.js +50 -0
  49. package/dist/system/host.js +48 -0
  50. package/dist/system/index.js +3 -0
  51. package/dist/system/types.js +1 -0
  52. package/dist/telegram/adapter.js +99 -0
  53. package/dist/telegram/constants.js +12 -0
  54. package/dist/telegram/index.js +3 -0
  55. package/dist/telegram/pairing.js +60 -0
  56. package/dist/telegram/types.js +1 -0
  57. package/package.json +43 -0
package/dist/cli.js ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { Argument, Command, CommanderError, InvalidArgumentError, Option } from 'commander';
3
+ import { realpathSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createClackPrompts, defaultFetchStatus, runInit, runOwner, runResetOwner, runStatus, runTest, runUninstall, waitForPairing, } from './commands/index.js';
8
+ import { DEFAULT_PORT, loadConfig, VERSION } from './config/index.js';
9
+ import { runService } from './daemon/index.js';
10
+ import { createTargets, resolveInstallPaths, TARGET_ORDER } from './install/index.js';
11
+ import { isPortFree, realExec, validateToken } from './system/index.js';
12
+ const PORT_PATTERN = /^\d+$/;
13
+ function resolvePort() {
14
+ try {
15
+ return loadConfig().port;
16
+ }
17
+ catch {
18
+ const port = Number(process.env.PORT);
19
+ return Number.isInteger(port) && port > 0 ? port : DEFAULT_PORT;
20
+ }
21
+ }
22
+ function parsePort(rawPort) {
23
+ const port = PORT_PATTERN.test(rawPort) ? Number(rawPort) : NaN;
24
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
25
+ throw new InvalidArgumentError('must be an integer 1-65535');
26
+ }
27
+ return port;
28
+ }
29
+ function runInitCommand(args) {
30
+ const paths = resolveInstallPaths();
31
+ const targets = createRealTargets(paths);
32
+ return runWithSigint((controller) => runInit(args, {
33
+ targets,
34
+ paths,
35
+ prompt: createClackPrompts(controller.signal),
36
+ validateToken,
37
+ isPortFree,
38
+ wait: waitForPairing,
39
+ print: (text) => console.log(text),
40
+ signal: controller.signal,
41
+ version: VERSION,
42
+ }));
43
+ }
44
+ function createRealTargets(paths) {
45
+ return createTargets({ exec: realExec, paths, env: process.env, user: os.userInfo().username }, { selfPath: realpathSync(fileURLToPath(import.meta.url)), nodePath: process.execPath });
46
+ }
47
+ async function runWithSigint(run) {
48
+ const controller = new AbortController();
49
+ const onSigint = () => controller.abort();
50
+ process.on('SIGINT', onSigint);
51
+ try {
52
+ return await run(controller);
53
+ }
54
+ finally {
55
+ process.off('SIGINT', onSigint);
56
+ }
57
+ }
58
+ function runStatusCommand() {
59
+ const paths = resolveInstallPaths();
60
+ return runStatus({
61
+ targets: createRealTargets(paths),
62
+ paths,
63
+ fetchStatus: defaultFetchStatus,
64
+ print: (text) => console.log(text),
65
+ });
66
+ }
67
+ function runResetOwnerCommand() {
68
+ const paths = resolveInstallPaths();
69
+ const targets = createRealTargets(paths);
70
+ return runWithSigint((controller) => runResetOwner({
71
+ targets,
72
+ paths,
73
+ confirm: createClackPrompts(controller.signal).confirm,
74
+ wait: waitForPairing,
75
+ print: (text) => console.log(text),
76
+ signal: controller.signal,
77
+ }));
78
+ }
79
+ function runUninstallCommand() {
80
+ const paths = resolveInstallPaths();
81
+ const targets = createRealTargets(paths);
82
+ return runWithSigint((controller) => runUninstall({
83
+ targets,
84
+ paths,
85
+ confirm: createClackPrompts(controller.signal).confirm,
86
+ print: (text) => console.log(text),
87
+ signal: controller.signal,
88
+ }));
89
+ }
90
+ /** Takes argv without node and script path; returns the exit code (2 for usage errors). */
91
+ export async function main(argv) {
92
+ let code = 0;
93
+ const program = new Command('telegram-ask').version(VERSION, '-v, --version').exitOverride();
94
+ program
95
+ .command('init')
96
+ .description('set up the bot token and install the service')
97
+ .addArgument(new Argument('[target]').choices(TARGET_ORDER))
98
+ .option('--port <n>', "API port (default 8787, or the existing install's)", parsePort)
99
+ // Installs an unpublished build; for testing.
100
+ .addOption(new Option('--tarball <path>').hideHelp())
101
+ .action(async (target, options) => {
102
+ code = await runInitCommand({
103
+ ...(target ? { target } : {}),
104
+ ...(options.port === undefined ? {} : { port: options.port }),
105
+ ...(options.tarball === undefined ? {} : { tarball: resolve(options.tarball) }),
106
+ });
107
+ });
108
+ program
109
+ .command('start')
110
+ .description('run the service in the foreground')
111
+ .action(() => runService());
112
+ program
113
+ .command('status')
114
+ .description('show service and pairing state')
115
+ .action(async () => {
116
+ code = await runStatusCommand();
117
+ });
118
+ program
119
+ .command('test')
120
+ .description('send a test approval request')
121
+ .action(async () => {
122
+ code = await runTest(resolvePort(), resolveInstallPaths().installFile);
123
+ });
124
+ program
125
+ .command('reset-owner')
126
+ .description('clear the paired owner so a new one can pair')
127
+ .action(async () => {
128
+ code = await runResetOwnerCommand();
129
+ });
130
+ program
131
+ .command('uninstall')
132
+ .description('remove the service')
133
+ .action(async () => {
134
+ code = await runUninstallCommand();
135
+ });
136
+ // Runs inside containers to read or clear the owner in the data volume.
137
+ program
138
+ .command('owner', { hidden: true })
139
+ .argument('[args...]')
140
+ .action((args) => {
141
+ code = runOwner(args);
142
+ });
143
+ try {
144
+ await program.parseAsync(argv.length === 0 ? ['--help'] : argv, { from: 'user' });
145
+ }
146
+ catch (error) {
147
+ if (!(error instanceof CommanderError)) {
148
+ throw error;
149
+ }
150
+ // Help and version exit 0; usage errors (already printed) exit 2.
151
+ return error.exitCode === 0 ? 0 : 2;
152
+ }
153
+ return code;
154
+ }
155
+ if (import.meta.main) {
156
+ const code = await main(process.argv.slice(2));
157
+ // `start` keeps the process alive via the server and bot; only exit for other commands.
158
+ if (process.argv[2] !== 'start') {
159
+ process.exit(code);
160
+ }
161
+ }
@@ -0,0 +1,14 @@
1
+ /** Display names for the install target prompt. */
2
+ export const TARGET_LABELS = {
3
+ quadlet: 'Podman Quadlet (user service)',
4
+ compose: 'Docker Compose',
5
+ systemd: 'systemd user service (npm)',
6
+ };
7
+ /** How long to wait for the service to answer /status for the first time. */
8
+ export const DEFAULT_START_TIMEOUT_MS = 60_000;
9
+ /** Delay between /status polls while waiting for pairing. */
10
+ export const POLL_INTERVAL_MS = 1000;
11
+ /** Per-request timeout for one /status call. */
12
+ export const STATUS_TIMEOUT_MS = 2000;
13
+ /** Strips the dash from a displayed pairing code to build the t.me deep link. */
14
+ export const CODE_DASH_PATTERN = /-/g;
@@ -0,0 +1,8 @@
1
+ export { runInit } from './init.js';
2
+ export { runOwner } from './owner.js';
3
+ export { defaultFetchStatus, waitForPairing } from './pairing-wait.js';
4
+ export { createClackPrompts, PromptAborted } from './prompts.js';
5
+ export { runResetOwner } from './reset-owner.js';
6
+ export { runStatus } from './status.js';
7
+ export { runTest } from './test.js';
8
+ export { runUninstall } from './uninstall.js';
@@ -0,0 +1,159 @@
1
+ import { DEFAULT_PORT } from '../config/index.js';
2
+ import { detectTargets, findDefaultTarget, findExistingInstall, findLeftoverSecrets, writeInstall, } from '../install/index.js';
3
+ import { TARGET_LABELS } from './constants.js';
4
+ import { PromptAborted } from './prompts.js';
5
+ async function findBotUsername(deps, token) {
6
+ try {
7
+ return await deps.validateToken(token);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ class Aborted extends Error {
14
+ }
15
+ function redact(message, secrets) {
16
+ let redacted = message;
17
+ for (const secret of secrets) {
18
+ if (secret !== '') {
19
+ redacted = redacted.replaceAll(secret, '***');
20
+ }
21
+ }
22
+ return redacted;
23
+ }
24
+ /**
25
+ * Installs or replaces the service and waits for pairing. Returns 0 on success or when the user
26
+ * declines, 1 on failure, 130 on interrupt; error text is shown with known tokens masked.
27
+ */
28
+ export async function runInit(args, deps) {
29
+ // Every token string init has seen, so error messages can be shown with them masked.
30
+ const seen = [];
31
+ try {
32
+ return await runInitInner(args, deps, seen);
33
+ }
34
+ catch (error) {
35
+ if (error instanceof Aborted || error instanceof PromptAborted || deps.signal?.aborted) {
36
+ deps.print('');
37
+ return 130;
38
+ }
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ deps.print(`Install failed: ${redact(message, seen)}`);
41
+ deps.print('Run telegram-ask init again. Check the logs for details.');
42
+ return 1;
43
+ }
44
+ }
45
+ async function runInitInner(args, deps, seen) {
46
+ const { print, prompt } = deps;
47
+ const env = deps.env ?? process.env;
48
+ const detected = await detectTargets(deps.targets);
49
+ let name;
50
+ if (args.target) {
51
+ const chosen = detected.find((candidate) => candidate.name === args.target);
52
+ if (!chosen?.ok) {
53
+ print(`Cannot use ${args.target}: ${chosen?.reason ?? 'unavailable'}`);
54
+ return 1;
55
+ }
56
+ name = args.target;
57
+ }
58
+ else {
59
+ const defaultName = findDefaultTarget(detected);
60
+ if (!defaultName) {
61
+ print('No install target is available:');
62
+ for (const candidate of detected) {
63
+ if (!candidate.ok) {
64
+ print(` ${candidate.name}: ${candidate.reason}`);
65
+ }
66
+ }
67
+ return 1;
68
+ }
69
+ name = await prompt.select('Where should the bot run?', detected.map((candidate) => ({
70
+ value: candidate.name,
71
+ label: TARGET_LABELS[candidate.name],
72
+ ...(candidate.ok ? {} : { disabled: candidate.reason }),
73
+ })), defaultName);
74
+ }
75
+ const target = deps.targets[name];
76
+ let secrets = {};
77
+ let record;
78
+ const existing = findExistingInstall(deps.paths, deps.targets, env);
79
+ if (existing) {
80
+ record = existing.record;
81
+ print(`Found an existing ${record.target} install (version ${record.version}, port ${record.port}).` +
82
+ (existing.legacy ? ' It looks like a legacy install from an older setup.' : ''));
83
+ if (!(await prompt.confirm('Replace it?', true))) {
84
+ return 0;
85
+ }
86
+ const old = deps.targets[record.target];
87
+ secrets = await old.readSecrets(record);
88
+ if (secrets.token) {
89
+ seen.push(secrets.token);
90
+ }
91
+ // Record a legacy install too, so a failed run below can still find its config on retry.
92
+ if (existing.legacy) {
93
+ writeInstall(deps.paths.installFile, record);
94
+ }
95
+ // Remove first: the old service may hold the port we're about to check. install.json stays
96
+ // until the new install replaces it; remove() keeps the secrets and is safe to repeat.
97
+ await old.remove(record, false);
98
+ }
99
+ else {
100
+ secrets = findLeftoverSecrets(deps.paths, env);
101
+ if (secrets.token) {
102
+ seen.push(secrets.token);
103
+ print('Found a bot token kept from an earlier install.');
104
+ }
105
+ }
106
+ let token;
107
+ if (secrets.token) {
108
+ const bot = await findBotUsername(deps, secrets.token);
109
+ if (bot) {
110
+ token = secrets.token;
111
+ print(`Reusing the saved bot token (@${bot}).`);
112
+ }
113
+ }
114
+ while (token === undefined) {
115
+ if (deps.signal?.aborted) {
116
+ throw new Aborted();
117
+ }
118
+ const candidate = await prompt.password('Bot token');
119
+ seen.push(candidate);
120
+ if (candidate !== '' && (await findBotUsername(deps, candidate))) {
121
+ token = candidate;
122
+ }
123
+ else {
124
+ print('Telegram rejected that token (or could not be reached). Try again.');
125
+ }
126
+ }
127
+ const port = args.port ?? record?.port ?? DEFAULT_PORT;
128
+ if (!(await deps.isPortFree(port))) {
129
+ print(`Port ${port} is in use. Pick another with --port.`);
130
+ return 1;
131
+ }
132
+ const installed = await target.install({
133
+ version: deps.version,
134
+ port,
135
+ token,
136
+ ...(secrets.ownerId === undefined ? {} : { ownerId: secrets.ownerId }),
137
+ ...(args.tarball === undefined ? {} : { tarball: args.tarball }),
138
+ print,
139
+ });
140
+ writeInstall(deps.paths.installFile, installed);
141
+ const result = await deps.wait({
142
+ port,
143
+ isRunning: () => target.isRunning(installed),
144
+ logsHint: target.logsHint(installed),
145
+ ...(deps.signal ? { signal: deps.signal } : {}),
146
+ print,
147
+ });
148
+ if (result === 'paired') {
149
+ print('Done. Try it: telegram-ask test');
150
+ print('Check state: telegram-ask status');
151
+ print(`Logs: ${target.logsHint(installed)}`);
152
+ return 0;
153
+ }
154
+ if (result === 'interrupted') {
155
+ return 130;
156
+ }
157
+ print(target.logsHint(installed));
158
+ return 1;
159
+ }
@@ -0,0 +1,18 @@
1
+ import { readConfigFile, resolveConfigPath, updateConfigFile } from '../config/index.js';
2
+ /** Hidden helper; runs inside containers so the CLI can read/clear the owner in the data volume. */
3
+ export function runOwner(args, env = process.env) {
4
+ const path = resolveConfigPath(env);
5
+ if (args[0] === 'get') {
6
+ const id = readConfigFile(path).ownerId;
7
+ if (id !== undefined) {
8
+ console.log(String(id));
9
+ }
10
+ return 0;
11
+ }
12
+ if (args[0] === 'clear') {
13
+ updateConfigFile(path, { ownerId: null });
14
+ return 0;
15
+ }
16
+ console.error('Usage: owner get|clear');
17
+ return 2;
18
+ }
@@ -0,0 +1,65 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { statusInfoSchema } from '../server/index.js';
3
+ import { CODE_DASH_PATTERN, DEFAULT_START_TIMEOUT_MS, POLL_INTERVAL_MS, STATUS_TIMEOUT_MS, } from './constants.js';
4
+ /**
5
+ * GETs the local /status; undefined on any failure or unexpected body (typically the service isn't
6
+ * up yet).
7
+ */
8
+ export async function defaultFetchStatus(port) {
9
+ try {
10
+ const response = await fetch(`http://127.0.0.1:${port}/status`, {
11
+ signal: AbortSignal.timeout(STATUS_TIMEOUT_MS),
12
+ });
13
+ const body = statusInfoSchema.safeParse(await response.json());
14
+ if (body.success) {
15
+ return body.data;
16
+ }
17
+ }
18
+ catch {
19
+ // not up yet
20
+ }
21
+ return undefined;
22
+ }
23
+ function createAbortableSleep(signal) {
24
+ // Rejects on abort; the loop checks the signal right after.
25
+ return (delayMs) => delay(delayMs, undefined, { signal }).catch(() => { });
26
+ }
27
+ /**
28
+ * Polls /status, printing the pairing code once. Start timeout only applies until the service first
29
+ * answers; ends as paired, interrupted, stopped (not running) or notStarted.
30
+ */
31
+ export async function waitForPairing(options) {
32
+ const print = options.print ?? ((text) => console.log(text));
33
+ const fetchStatus = options.fetchStatus ?? defaultFetchStatus;
34
+ const sleep = options.sleep ?? createAbortableSleep(options.signal);
35
+ const startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
36
+ const began = Date.now();
37
+ let answered = false;
38
+ let shown = false;
39
+ for (;;) {
40
+ if (options.signal?.aborted) {
41
+ print('Service is still running and waiting for pairing. Run "telegram-ask status" to see the code again.');
42
+ return 'interrupted';
43
+ }
44
+ const status = await fetchStatus(options.port);
45
+ if (status) {
46
+ answered = true;
47
+ if (status.paired) {
48
+ return 'paired';
49
+ }
50
+ if (!shown) {
51
+ shown = true;
52
+ const rawCode = status.code.replace(CODE_DASH_PATTERN, '');
53
+ print(`Pairing code: ${status.code}\nSend "/start ${status.code}" to @${status.bot}, or open https://t.me/${status.bot}?start=${rawCode}`);
54
+ }
55
+ }
56
+ else if (!answered && Date.now() - began >= startTimeoutMs) {
57
+ return 'notStarted';
58
+ }
59
+ if (!(await options.isRunning())) {
60
+ print(`The service stopped. ${options.logsHint}`);
61
+ return 'stopped';
62
+ }
63
+ await sleep(POLL_INTERVAL_MS);
64
+ }
65
+ }
@@ -0,0 +1,55 @@
1
+ import { confirm, isCancel, password, select } from '@clack/prompts';
2
+ /**
3
+ * Thrown by the prompts on Ctrl-C, abort, or when stdin isn't a terminal; callers treat it as a
4
+ * user cancel.
5
+ */
6
+ export class PromptAborted extends Error {
7
+ constructor() {
8
+ super('prompt aborted');
9
+ this.name = 'PromptAborted';
10
+ }
11
+ }
12
+ // Clack never settles on a closed or piped stdin, so refuse up front.
13
+ function requireTty() {
14
+ if (!process.stdin.isTTY) {
15
+ throw new PromptAborted();
16
+ }
17
+ }
18
+ function requireAnswer(value) {
19
+ if (isCancel(value)) {
20
+ throw new PromptAborted();
21
+ }
22
+ return value;
23
+ }
24
+ /**
25
+ * Clack-backed prompts. Each rejects with PromptAborted on Ctrl-C, when `signal` aborts, or when
26
+ * stdin isn't a terminal.
27
+ */
28
+ export function createClackPrompts(signal) {
29
+ return {
30
+ confirm: async (message, initialValue) => {
31
+ requireTty();
32
+ return requireAnswer(await confirm({ message, initialValue, signal }));
33
+ },
34
+ password: async (message) => {
35
+ requireTty();
36
+ return requireAnswer(await password({ message, signal })).trim();
37
+ },
38
+ select: async (message, options, initialValue) => {
39
+ requireTty();
40
+ // select<string>: clack's Option<T> is a conditional type TS can't check against a generic T.
41
+ return requireAnswer(await select({
42
+ message,
43
+ initialValue,
44
+ signal,
45
+ options: options.map((option) => ({
46
+ value: option.value,
47
+ label: option.disabled
48
+ ? `${option.label} (unavailable: ${option.disabled})`
49
+ : option.label,
50
+ disabled: option.disabled !== undefined,
51
+ })),
52
+ }));
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,58 @@
1
+ import { findExistingInstall } from '../install/index.js';
2
+ import { PromptAborted } from './prompts.js';
3
+ /**
4
+ * Unlinks the paired owner, restarts the service and waits for a new pairing. Returns 0 on success
5
+ * or if the user declines, 1 on failure or not installed, 130 on interrupt.
6
+ */
7
+ export async function runResetOwner(deps) {
8
+ const { print } = deps;
9
+ const existing = findExistingInstall(deps.paths, deps.targets);
10
+ if (!existing) {
11
+ print('Not installed. Run "telegram-ask init".');
12
+ return 1;
13
+ }
14
+ const { record } = existing;
15
+ const target = deps.targets[record.target];
16
+ try {
17
+ if (!(await deps.confirm('This unlinks your Telegram account. Continue?', false))) {
18
+ return 0;
19
+ }
20
+ await target.clearOwner(record);
21
+ await target.restart(record);
22
+ }
23
+ catch (error) {
24
+ if (error instanceof PromptAborted || deps.signal?.aborted) {
25
+ print('');
26
+ return 130;
27
+ }
28
+ print(`Reset failed. ${target.logsHint(record)}`);
29
+ return 1;
30
+ }
31
+ let result;
32
+ try {
33
+ result = await deps.wait({
34
+ port: record.port,
35
+ isRunning: () => target.isRunning(record),
36
+ logsHint: target.logsHint(record),
37
+ ...(deps.signal ? { signal: deps.signal } : {}),
38
+ print,
39
+ });
40
+ }
41
+ catch {
42
+ if (deps.signal?.aborted) {
43
+ print('');
44
+ return 130;
45
+ }
46
+ print(`Waiting for pairing failed. ${target.logsHint(record)}`);
47
+ return 1;
48
+ }
49
+ if (result === 'paired') {
50
+ print('Paired. Try it: telegram-ask test');
51
+ return 0;
52
+ }
53
+ if (result === 'interrupted') {
54
+ return 130;
55
+ }
56
+ print(target.logsHint(record));
57
+ return 1;
58
+ }
@@ -0,0 +1,42 @@
1
+ import { findExistingInstall } from '../install/index.js';
2
+ import { CODE_DASH_PATTERN } from './constants.js';
3
+ /**
4
+ * Prints install, running and pairing state. Returns 0 only when installed and running; 1
5
+ * otherwise.
6
+ */
7
+ export async function runStatus(deps) {
8
+ const { print } = deps;
9
+ const existing = findExistingInstall(deps.paths, deps.targets);
10
+ if (!existing) {
11
+ print('Not installed. Run "telegram-ask init".');
12
+ return 1;
13
+ }
14
+ const { record } = existing;
15
+ let running = false;
16
+ try {
17
+ running = await deps.targets[record.target].isRunning(record);
18
+ }
19
+ catch {
20
+ running = false;
21
+ }
22
+ print(`Target: ${record.target}`);
23
+ print(`Version: ${record.version}`);
24
+ print(`Port: ${record.port}`);
25
+ print(`Running: ${running ? 'yes' : 'no'}`);
26
+ if (!running) {
27
+ print(deps.targets[record.target].logsHint(record));
28
+ return 1;
29
+ }
30
+ const status = await deps.fetchStatus(record.port);
31
+ if (!status) {
32
+ print('Pairing: unknown (the service did not answer on /status yet)');
33
+ }
34
+ else if (status.paired) {
35
+ print('Pairing: paired');
36
+ }
37
+ else {
38
+ const rawCode = status.code.replace(CODE_DASH_PATTERN, '');
39
+ print(`Pairing: not paired\nPairing code: ${status.code}\nSend "/start ${status.code}" to @${status.bot}, or open https://t.me/${status.bot}?start=${rawCode}`);
40
+ }
41
+ return 0;
42
+ }
@@ -0,0 +1,22 @@
1
+ import { readInstall } from '../install/index.js';
2
+ /** Port from install.json when present, else the fallback. */
3
+ export async function runTest(fallbackPort, installFile) {
4
+ const port = (installFile ? readInstall(installFile)?.port : undefined) ?? fallbackPort;
5
+ try {
6
+ const response = await fetch(`http://127.0.0.1:${port}/request`, {
7
+ method: 'POST',
8
+ headers: { 'content-type': 'application/json' },
9
+ body: JSON.stringify({
10
+ text: 'This is a test',
11
+ options: ['Approve', 'Decline'],
12
+ timeoutOption: 'Decline',
13
+ }),
14
+ });
15
+ console.log(response.status, await response.text());
16
+ return response.status === 200 ? 0 : 1;
17
+ }
18
+ catch (error) {
19
+ console.error(`could not reach the service on port ${port} (is it running? try telegram-ask status): ${error instanceof Error ? error.message : String(error)}`);
20
+ return 1;
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { findExistingInstall, removeInstall } from '../install/index.js';
2
+ import { PromptAborted } from './prompts.js';
3
+ /**
4
+ * Removes the service after asking whether to purge the token and owner. Returns 0 on success or
5
+ * when nothing is installed, 1 on failure, 130 on interrupt.
6
+ */
7
+ export async function runUninstall(deps) {
8
+ const { print } = deps;
9
+ const existing = findExistingInstall(deps.paths, deps.targets);
10
+ if (!existing) {
11
+ print('Not installed.');
12
+ return 0;
13
+ }
14
+ const { record } = existing;
15
+ try {
16
+ const purge = await deps.confirm('Also delete the bot token and paired owner?', false);
17
+ await deps.targets[record.target].remove(record, purge);
18
+ removeInstall(deps.paths.installFile);
19
+ }
20
+ catch (error) {
21
+ if (error instanceof PromptAborted || deps.signal?.aborted) {
22
+ print('');
23
+ return 130;
24
+ }
25
+ print('Uninstall failed. Check the service logs and try again.');
26
+ return 1;
27
+ }
28
+ print('Uninstalled.');
29
+ return 0;
30
+ }
@@ -0,0 +1,13 @@
1
+ import packageJson from '../../package.json' with { type: 'json' };
2
+ /** Package version, read from package.json when the module loads. */
3
+ export const VERSION = packageJson.version;
4
+ /** Port used when neither the env nor the config file sets one. */
5
+ export const DEFAULT_PORT = 8787;
6
+ /** Loopback only; the container image overrides it with HOST=0.0.0.0. */
7
+ export const DEFAULT_HOST = '127.0.0.1';
8
+ /** Appended to config errors so the user knows how to fix them. */
9
+ export const INIT_HINT = 'run `telegram-ask init` to set up the bot';
10
+ /** Accepts optionally signed whole numbers in env values. */
11
+ export const INTEGER_PATTERN = /^-?\d+$/;
12
+ /** Owner read/write only, for files that hold the bot token. */
13
+ export const SECRET_FILE_MODE = 0o600;
@@ -0,0 +1,2 @@
1
+ export { DEFAULT_PORT, VERSION } from './constants.js';
2
+ export { loadConfig, readConfigFile, resolveConfigDirectory, resolveConfigPath, updateConfigFile, } from './store.js';
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ /** Loose so unknown keys survive `updateConfigFile`'s read-modify-write. */
3
+ export const fileConfigSchema = z.looseObject({
4
+ botToken: z.string().optional(),
5
+ ownerId: z.number().optional(),
6
+ port: z.number().optional(),
7
+ });