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
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { isMissingFileError, writeFileAtomic } from '../system/index.js';
5
+ import { DEFAULT_HOST, DEFAULT_PORT, INIT_HINT, INTEGER_PATTERN, SECRET_FILE_MODE, } from './constants.js';
6
+ import { fileConfigSchema } from './schema.js';
7
+ function findEnvValue(env, key) {
8
+ const value = env[key];
9
+ return value === undefined || value === '' ? undefined : value;
10
+ }
11
+ /**
12
+ * Path of config.json: $TELEGRAM_ASK_CONFIG, else under $XDG_CONFIG_HOME or ~/.config. Empty env
13
+ * values count as unset.
14
+ */
15
+ export function resolveConfigPath(env = process.env) {
16
+ const explicit = findEnvValue(env, 'TELEGRAM_ASK_CONFIG');
17
+ if (explicit) {
18
+ return explicit;
19
+ }
20
+ const xdgConfigHome = findEnvValue(env, 'XDG_CONFIG_HOME');
21
+ const base = xdgConfigHome ?? join(findEnvValue(env, 'HOME') ?? homedir(), '.config');
22
+ return join(base, 'telegram-ask', 'config.json');
23
+ }
24
+ /** Default config directory, ignoring $TELEGRAM_ASK_CONFIG; install files live here. */
25
+ export function resolveConfigDirectory(env = process.env) {
26
+ const { TELEGRAM_ASK_CONFIG: _ignored, ...rest } = env;
27
+ return dirname(resolveConfigPath(rest));
28
+ }
29
+ /**
30
+ * Returns {} when the file is missing. Throws on invalid JSON or fields, without quoting the file,
31
+ * since it may hold the bot token. Read errors other than a missing file are rethrown.
32
+ */
33
+ export function readConfigFile(path) {
34
+ let text;
35
+ try {
36
+ text = readFileSync(path, 'utf8');
37
+ }
38
+ catch (error) {
39
+ if (isMissingFileError(error)) {
40
+ return {};
41
+ }
42
+ throw error;
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(text);
47
+ }
48
+ catch {
49
+ // Don't include the parser's message: V8 can quote part of the file, which may hold the bot
50
+ // token.
51
+ throw new Error(`config file ${JSON.stringify(path)} contains invalid JSON; ${INIT_HINT}`);
52
+ }
53
+ const result = fileConfigSchema.safeParse(parsed);
54
+ // Don't include zod's issues either; keep the token out of error output.
55
+ if (!result.success) {
56
+ throw new Error(`config file ${JSON.stringify(path)} has invalid or missing fields; ${INIT_HINT}`);
57
+ }
58
+ return result.data;
59
+ }
60
+ function toInteger(value) {
61
+ if (typeof value === 'number') {
62
+ return Number.isInteger(value) ? value : undefined;
63
+ }
64
+ if (typeof value === 'string' && INTEGER_PATTERN.test(value.trim())) {
65
+ return Number(value.trim());
66
+ }
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Env vars (BOT_TOKEN, OWNER_ID, PORT, HOST) override the config file. Throws a user-facing error
71
+ * on a missing token or invalid owner id or port; read errors other than a missing config file
72
+ * are rethrown.
73
+ */
74
+ export function loadConfig(env = process.env) {
75
+ const file = readConfigFile(resolveConfigPath(env));
76
+ const botToken = findEnvValue(env, 'BOT_TOKEN') ?? file.botToken;
77
+ if (typeof botToken !== 'string' || botToken === '') {
78
+ throw new Error(`missing bot token; ${INIT_HINT} (or set BOT_TOKEN)`);
79
+ }
80
+ const rawOwnerId = findEnvValue(env, 'OWNER_ID') ?? file.ownerId;
81
+ const ownerId = rawOwnerId === undefined ? undefined : toInteger(rawOwnerId);
82
+ if (rawOwnerId !== undefined && (ownerId === undefined || !Number.isSafeInteger(ownerId))) {
83
+ throw new Error(`invalid owner id; ${INIT_HINT} (or set OWNER_ID)`);
84
+ }
85
+ const rawPort = findEnvValue(env, 'PORT') ?? file.port ?? DEFAULT_PORT;
86
+ const port = toInteger(rawPort);
87
+ if (port === undefined || port < 1 || port > 65535) {
88
+ throw new Error(`invalid port ${JSON.stringify(String(rawPort))}: must be an integer 1-65535`);
89
+ }
90
+ return { botToken, ownerId, port, host: findEnvValue(env, 'HOST') ?? DEFAULT_HOST };
91
+ }
92
+ /** Merges `patch` into the file's JSON object; a `null` value deletes that key. */
93
+ export function updateConfigFile(path, patch) {
94
+ const next = { ...readConfigFile(path) };
95
+ for (const [key, value] of Object.entries(patch)) {
96
+ if (value === null) {
97
+ delete next[key];
98
+ }
99
+ else if (value !== undefined) {
100
+ next[key] = value;
101
+ }
102
+ }
103
+ writeFileAtomic(path, `${JSON.stringify(next, null, 2)}\n`, SECRET_FILE_MODE);
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export { runService } from './service.js';
@@ -0,0 +1,82 @@
1
+ import { Bot, GrammyError } from 'grammy';
2
+ import { ApprovalService } from '../approval/index.js';
3
+ import { loadConfig, resolveConfigPath } from '../config/index.js';
4
+ import { createApiServer } from '../server/index.js';
5
+ import { createOnPaired, createTelegramMessenger, formatCode, generateCode, Pairing, registerPairingHandler, registerTapHandler, } from '../telegram/index.js';
6
+ /**
7
+ * Starts the bot and API. Exits the process with 1 on bad config, a rejected token, Telegram being
8
+ * unreachable at startup, an HTTP server error, or a fatal polling error (409 means another
9
+ * instance is polling with the same token).
10
+ */
11
+ export async function runService(env = process.env) {
12
+ let config;
13
+ try {
14
+ config = loadConfig(env);
15
+ }
16
+ catch (error) {
17
+ console.error(error instanceof Error ? error.message : String(error));
18
+ process.exit(1);
19
+ }
20
+ const owner = { id: config.ownerId };
21
+ const bot = new Bot(config.botToken);
22
+ const service = new ApprovalService(createTelegramMessenger(bot, owner));
23
+ registerTapHandler(bot, owner, (id, optionIndex) => service.tap(id, optionIndex));
24
+ // Log handler errors instead of crashing; grammY's error text doesn't include the token.
25
+ bot.catch((error) => {
26
+ console.error('bot error:', error.error instanceof Error ? error.error.message : error.error);
27
+ });
28
+ try {
29
+ await bot.init();
30
+ }
31
+ catch (error) {
32
+ // Don't print the error text; it can embed the request URL with the token.
33
+ console.error(error instanceof GrammyError
34
+ ? `Telegram rejected the bot token (${error.error_code}); check it with \`telegram-ask init\``
35
+ : 'could not reach Telegram to verify the bot token');
36
+ process.exit(1);
37
+ }
38
+ const botUsername = bot.botInfo.username;
39
+ let pairing;
40
+ if (owner.id === undefined) {
41
+ const code = generateCode();
42
+ pairing = new Pairing(code);
43
+ console.log(`Not paired. Send "/start ${formatCode(code)}" to @${botUsername}, or open https://t.me/${botUsername}?start=${code}`);
44
+ registerPairingHandler(bot, pairing, createOnPaired(owner, resolveConfigPath(env)));
45
+ }
46
+ const server = createApiServer(service, () => owner.id !== undefined || pairing === undefined || pairing.code === undefined
47
+ ? { paired: true, bot: botUsername }
48
+ : { paired: false, code: formatCode(pairing.code), bot: botUsername });
49
+ server.listen({ port: config.port, host: config.host }).then(() => console.log(`listening on ${config.host}:${config.port}`), (error) => {
50
+ console.error('http server error:', error instanceof Error ? error.message : String(error));
51
+ process.exit(1);
52
+ });
53
+ let stopping = false;
54
+ function shutdown() {
55
+ if (stopping) {
56
+ return;
57
+ }
58
+ stopping = true;
59
+ setTimeout(() => process.exit(0), 5000).unref();
60
+ server.close().catch(() => { });
61
+ bot
62
+ .stop()
63
+ .catch(() => { })
64
+ .finally(() => process.exit(0));
65
+ }
66
+ process.on('SIGTERM', shutdown);
67
+ process.on('SIGINT', shutdown);
68
+ bot
69
+ .start({ onStart: (botUser) => console.log(`bot @${botUser.username} polling`) })
70
+ .catch((error) => {
71
+ if (stopping) {
72
+ return;
73
+ }
74
+ if (error instanceof GrammyError && error.error_code === 409) {
75
+ console.error('another instance is already polling with this bot token; exiting');
76
+ }
77
+ else {
78
+ console.error('bot stopped:', error instanceof Error ? error.message : String(error));
79
+ }
80
+ process.exit(1);
81
+ });
82
+ }
@@ -0,0 +1,123 @@
1
+ import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { DOCKERIGNORE, INTEGER_PATTERN, PACKAGE_NAME } from './constants.js';
4
+ import { readEnvFile, removeEnvKey, writeEnvFile } from './envfile.js';
5
+ import { formatComposeYaml, formatDockerfile, formatEnvFile } from './templates.js';
6
+ async function tryExec(exec, command, args) {
7
+ try {
8
+ return await exec(command, args);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ /**
15
+ * Docker Compose target; writes its files under the compose directory and drives `docker compose`.
16
+ */
17
+ export function createComposeTarget(deps) {
18
+ const { exec, paths } = deps;
19
+ const file = join(paths.composeDirectory, 'compose.yaml');
20
+ const envPath = join(paths.composeDirectory, '.env');
21
+ const compose = (args, options) => exec('docker', ['compose', '-f', file, ...args], options);
22
+ return {
23
+ name: 'compose',
24
+ async available() {
25
+ const version = await tryExec(exec, 'docker', ['compose', 'version']);
26
+ if (version === undefined) {
27
+ return { ok: false, reason: 'docker not installed' };
28
+ }
29
+ if (version.code !== 0) {
30
+ return { ok: false, reason: 'docker compose v2 not available' };
31
+ }
32
+ const info = await tryExec(exec, 'docker', ['info']);
33
+ if (info === undefined || info.code !== 0) {
34
+ return { ok: false, reason: "can't talk to docker (not in the docker group?)" };
35
+ }
36
+ return { ok: true };
37
+ },
38
+ async install(options) {
39
+ mkdirSync(paths.composeDirectory, { recursive: true });
40
+ if (options.tarball !== undefined) {
41
+ const destination = join(paths.composeDirectory, basename(options.tarball));
42
+ if (resolve(options.tarball) !== resolve(destination)) {
43
+ copyFileSync(options.tarball, destination);
44
+ }
45
+ }
46
+ // Keep the token out of the build context.
47
+ writeFileSync(join(paths.composeDirectory, '.dockerignore'), DOCKERIGNORE);
48
+ writeFileSync(join(paths.composeDirectory, 'Dockerfile'), formatDockerfile({
49
+ version: options.version,
50
+ ...(options.tarball ? { tarball: options.tarball } : {}),
51
+ }));
52
+ writeFileSync(file, formatComposeYaml(options.port));
53
+ writeEnvFile(envPath, formatEnvFile({
54
+ token: options.token,
55
+ port: options.port,
56
+ ...(options.ownerId === undefined ? {} : { ownerId: options.ownerId }),
57
+ }));
58
+ const result = await compose(['up', '-d', '--build'], { inherit: true });
59
+ if (result.code !== 0) {
60
+ throw new Error(`docker compose up failed (exit ${result.code})`);
61
+ }
62
+ return { target: 'compose', port: options.port, version: options.version };
63
+ },
64
+ async readSecrets() {
65
+ const values = readEnvFile(envPath);
66
+ const tokenSecrets = values.BOT_TOKEN ? { token: values.BOT_TOKEN } : {};
67
+ if (values.OWNER_ID && INTEGER_PATTERN.test(values.OWNER_ID)) {
68
+ return { ...tokenSecrets, ownerId: Number(values.OWNER_ID) };
69
+ }
70
+ const result = await compose([
71
+ 'run',
72
+ '--rm',
73
+ '--no-deps',
74
+ 'bot',
75
+ PACKAGE_NAME,
76
+ 'owner',
77
+ 'get',
78
+ ]);
79
+ const output = result.stdout.trim();
80
+ if (result.code === 0 && INTEGER_PATTERN.test(output)) {
81
+ return { ...tokenSecrets, ownerId: Number(output) };
82
+ }
83
+ return tokenSecrets;
84
+ },
85
+ async remove(_record, purge) {
86
+ const result = await compose(purge ? ['down', '-v'] : ['down']);
87
+ if (result.code !== 0) {
88
+ throw new Error(`docker compose down failed: ${result.stderr.trim()}`);
89
+ }
90
+ if (purge) {
91
+ rmSync(paths.composeDirectory, { recursive: true, force: true });
92
+ }
93
+ },
94
+ async restart() {
95
+ const result = await compose(['up', '-d', '--force-recreate']);
96
+ if (result.code !== 0) {
97
+ throw new Error(`docker compose up failed (exit ${result.code})`);
98
+ }
99
+ },
100
+ async clearOwner() {
101
+ removeEnvKey(envPath, 'OWNER_ID');
102
+ const result = await compose([
103
+ 'run',
104
+ '--rm',
105
+ '--no-deps',
106
+ 'bot',
107
+ PACKAGE_NAME,
108
+ 'owner',
109
+ 'clear',
110
+ ]);
111
+ if (result.code !== 0) {
112
+ throw new Error(`couldn't clear the owner in the container: ${result.stderr.trim()}`);
113
+ }
114
+ },
115
+ async isRunning() {
116
+ const result = await compose(['ps', '--status', 'running', '-q', 'bot']);
117
+ return result.code === 0 && result.stdout.trim() !== '';
118
+ },
119
+ logsHint() {
120
+ return `docker compose -f ${file} logs -f`;
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,28 @@
1
+ /** Preference order; the first available target is the default. */
2
+ export const TARGET_ORDER = ['quadlet', 'compose', 'systemd'];
3
+ /** Name of the systemd unit and container. */
4
+ export const SERVICE_NAME = 'telegram-ask';
5
+ /** npm package name, also the container's command. */
6
+ export const PACKAGE_NAME = 'telegram-ask';
7
+ /** Podman volume holding /data (the config file). */
8
+ export const VOLUME_NAME = 'telegram-ask-data';
9
+ /** Keeps the .env file with the bot token out of the image build context. */
10
+ export const DOCKERIGNORE = '.env\n';
11
+ /** Quadlet needs podman 4.4 or newer (major part). */
12
+ export const MIN_PODMAN_MAJOR = 4;
13
+ /** Quadlet needs podman 4.4 or newer (minor part). */
14
+ export const MIN_PODMAN_MINOR = 4;
15
+ /** Whole numbers in env files and command output. */
16
+ export const INTEGER_PATTERN = /^-?\d+$/;
17
+ /** podman stderr meaning the thing is already gone, which purge treats as success. */
18
+ export const NOT_FOUND_PATTERN = /no such|not found|does not exist/i;
19
+ /** Captures major, minor and optional patch from `podman --version`. */
20
+ export const PODMAN_VERSION_PATTERN = /(\d+)\.(\d+)(?:\.(\d+))?/;
21
+ /** Matches an `Environment=` line in a unit file. */
22
+ export const ENVIRONMENT_LINE_PATTERN = /^\s*Environment=(.*)$/;
23
+ /** Matches a backslash escape inside a quoted unit value. */
24
+ export const ESCAPED_CHAR_PATTERN = /\\(.)/g;
25
+ /** Variable the unit sets to pin the config file path. */
26
+ export const CONFIG_ENV_PREFIX = 'TELEGRAM_ASK_CONFIG=';
27
+ /** Owner read/write only, for files that hold the bot token. */
28
+ export const SECRET_FILE_MODE = 0o600;
@@ -0,0 +1,50 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { isMissingFileError, writeFileAtomic } from '../system/index.js';
3
+ import { SECRET_FILE_MODE } from './constants.js';
4
+ function readTextIfPresent(path) {
5
+ try {
6
+ return readFileSync(path, 'utf8');
7
+ }
8
+ catch (error) {
9
+ if (isMissingFileError(error)) {
10
+ return undefined;
11
+ }
12
+ throw error;
13
+ }
14
+ }
15
+ /**
16
+ * Parses KEY=VALUE lines, skipping blanks and comments; returns {} when the file is missing. Values
17
+ * are taken literally (no unquoting).
18
+ */
19
+ export function readEnvFile(path) {
20
+ const text = readTextIfPresent(path);
21
+ if (text === undefined) {
22
+ return {};
23
+ }
24
+ const values = {};
25
+ for (const line of text.split('\n')) {
26
+ if (line.trim() === '' || line.startsWith('#')) {
27
+ continue;
28
+ }
29
+ const index = line.indexOf('=');
30
+ if (index > 0) {
31
+ values[line.slice(0, index)] = line.slice(index + 1);
32
+ }
33
+ }
34
+ return values;
35
+ }
36
+ /** Atomic write with owner-only permissions, since the file holds the bot token. */
37
+ export function writeEnvFile(path, contents) {
38
+ writeFileAtomic(path, contents, SECRET_FILE_MODE);
39
+ }
40
+ /**
41
+ * Drops every `key=` line, rewriting the file atomically; does nothing when the file is missing.
42
+ */
43
+ export function removeEnvKey(path, key) {
44
+ const text = readTextIfPresent(path);
45
+ if (text === undefined) {
46
+ return;
47
+ }
48
+ const kept = text.split('\n').filter((line) => !line.startsWith(`${key}=`));
49
+ writeEnvFile(path, kept.join('\n'));
50
+ }
@@ -0,0 +1,9 @@
1
+ export { createComposeTarget } from './compose.js';
2
+ export { TARGET_ORDER } from './constants.js';
3
+ export { readEnvFile, removeEnvKey, writeEnvFile } from './envfile.js';
4
+ export { resolveInstallPaths } from './paths.js';
5
+ export { createQuadletTarget } from './quadlet.js';
6
+ export { createTargets, detectTargets, findDefaultTarget, findExistingInstall, findLeftoverSecrets, } from './registry.js';
7
+ export { readInstall, removeInstall, writeInstall } from './state.js';
8
+ export { createSystemdTarget, findUnitConfigFile } from './systemd.js';
9
+ export { formatComposeYaml, formatDockerfile, formatEnvFile, formatQuadletContainer, formatSystemdUnit, quoteExecWord, quoteSystemdValue, } from './templates.js';
@@ -0,0 +1,23 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { resolveConfigDirectory } from '../config/index.js';
4
+ function toNonEmpty(value) {
5
+ return value === undefined || value === '' ? undefined : value;
6
+ }
7
+ /** Where each target keeps its files, derived from the env; touches no files. */
8
+ export function resolveInstallPaths(env = process.env) {
9
+ const home = toNonEmpty(env.HOME) ?? homedir();
10
+ const configDirectory = resolveConfigDirectory(env);
11
+ const xdgConfigHome = toNonEmpty(env.XDG_CONFIG_HOME) ?? join(home, '.config');
12
+ const xdgDataHome = toNonEmpty(env.XDG_DATA_HOME) ?? join(home, '.local', 'share');
13
+ return {
14
+ configDirectory,
15
+ configFile: join(configDirectory, 'config.json'),
16
+ installFile: join(configDirectory, 'install.json'),
17
+ composeDirectory: join(configDirectory, 'compose'),
18
+ quadletDirectory: join(configDirectory, 'quadlet'),
19
+ systemdUnit: join(xdgConfigHome, 'systemd', 'user', 'telegram-ask.service'),
20
+ quadletUnit: join(xdgConfigHome, 'containers', 'systemd', 'telegram-ask.container'),
21
+ userNpmPrefix: join(xdgDataHome, 'telegram-ask', 'npm'),
22
+ };
23
+ }
@@ -0,0 +1,167 @@
1
+ import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { ensureLinger } from '../system/index.js';
4
+ import { DOCKERIGNORE, INTEGER_PATTERN, MIN_PODMAN_MAJOR, MIN_PODMAN_MINOR, NOT_FOUND_PATTERN, PODMAN_VERSION_PATTERN, SERVICE_NAME, VOLUME_NAME, } from './constants.js';
5
+ import { readEnvFile, removeEnvKey, writeEnvFile } from './envfile.js';
6
+ import { formatDockerfile, formatEnvFile, formatQuadletContainer } from './templates.js';
7
+ function formatImageTag(version) {
8
+ return `localhost/${SERVICE_NAME}:${version}`;
9
+ }
10
+ /**
11
+ * Podman Quadlet target (needs podman 4.4+); builds the image locally and runs it as a user systemd
12
+ * unit.
13
+ */
14
+ export function createQuadletTarget(deps) {
15
+ const { exec, paths, user } = deps;
16
+ const envPath = join(paths.quadletDirectory, '.env');
17
+ const systemctl = (...args) => exec('systemctl', ['--user', ...args]);
18
+ return {
19
+ name: 'quadlet',
20
+ async available() {
21
+ let output;
22
+ try {
23
+ const result = await exec('podman', ['--version']);
24
+ if (result.code !== 0) {
25
+ return { ok: false, reason: 'podman not installed' };
26
+ }
27
+ output = result.stdout;
28
+ }
29
+ catch {
30
+ return { ok: false, reason: 'podman not installed' };
31
+ }
32
+ const match = PODMAN_VERSION_PATTERN.exec(output);
33
+ if (!match) {
34
+ return { ok: false, reason: `can't parse podman version: ${output.trim()}` };
35
+ }
36
+ const major = Number(match[1]);
37
+ const minor = Number(match[2]);
38
+ if (major > MIN_PODMAN_MAJOR || (major === MIN_PODMAN_MAJOR && minor >= MIN_PODMAN_MINOR)) {
39
+ return { ok: true };
40
+ }
41
+ return { ok: false, reason: `podman ${match[0]} is too old; quadlet needs 4.4 or newer` };
42
+ },
43
+ async install(options) {
44
+ mkdirSync(paths.quadletDirectory, { recursive: true });
45
+ if (options.tarball !== undefined) {
46
+ const destination = join(paths.quadletDirectory, basename(options.tarball));
47
+ if (resolve(options.tarball) !== resolve(destination)) {
48
+ copyFileSync(options.tarball, destination);
49
+ }
50
+ }
51
+ // Keep the token out of the build context.
52
+ writeFileSync(join(paths.quadletDirectory, '.dockerignore'), DOCKERIGNORE);
53
+ writeFileSync(join(paths.quadletDirectory, 'Dockerfile'), formatDockerfile({
54
+ version: options.version,
55
+ ...(options.tarball ? { tarball: options.tarball } : {}),
56
+ }));
57
+ writeEnvFile(envPath, formatEnvFile({
58
+ token: options.token,
59
+ port: options.port,
60
+ ...(options.ownerId === undefined ? {} : { ownerId: options.ownerId }),
61
+ }));
62
+ const build = await exec('podman', ['build', '-t', formatImageTag(options.version), paths.quadletDirectory], { inherit: true });
63
+ if (build.code !== 0) {
64
+ throw new Error(`podman build failed (exit ${build.code})`);
65
+ }
66
+ mkdirSync(dirname(paths.quadletUnit), { recursive: true });
67
+ writeFileSync(paths.quadletUnit, formatQuadletContainer({ version: options.version, port: options.port, envFile: envPath }));
68
+ for (const args of [['daemon-reload'], ['start', SERVICE_NAME]]) {
69
+ const result = await systemctl(...args);
70
+ if (result.code !== 0) {
71
+ throw new Error(`systemctl ${args.join(' ')} failed: ${result.stderr.trim()}`);
72
+ }
73
+ }
74
+ await ensureLinger(exec, user, options.print);
75
+ return { target: 'quadlet', port: options.port, version: options.version };
76
+ },
77
+ async readSecrets(record) {
78
+ const values = readEnvFile(envPath);
79
+ const tokenSecrets = values.BOT_TOKEN ? { token: values.BOT_TOKEN } : {};
80
+ if (values.OWNER_ID && INTEGER_PATTERN.test(values.OWNER_ID)) {
81
+ return { ...tokenSecrets, ownerId: Number(values.OWNER_ID) };
82
+ }
83
+ const result = await exec('podman', [
84
+ 'run',
85
+ '--rm',
86
+ '-v',
87
+ `${VOLUME_NAME}:/data`,
88
+ formatImageTag(record.version),
89
+ SERVICE_NAME,
90
+ 'owner',
91
+ 'get',
92
+ ]);
93
+ const output = result.stdout.trim();
94
+ if (result.code === 0 && INTEGER_PATTERN.test(output)) {
95
+ return { ...tokenSecrets, ownerId: Number(output) };
96
+ }
97
+ return tokenSecrets;
98
+ },
99
+ async remove(record, purge) {
100
+ await systemctl('stop', SERVICE_NAME);
101
+ rmSync(paths.quadletUnit, { force: true });
102
+ await systemctl('daemon-reload');
103
+ if (!purge) {
104
+ return;
105
+ }
106
+ const failures = [];
107
+ const step = async (label, args) => {
108
+ try {
109
+ const result = await exec('podman', args);
110
+ if (result.code === 0 || NOT_FOUND_PATTERN.test(result.stderr)) {
111
+ return true;
112
+ }
113
+ failures.push(`${label}: ${result.stderr.trim() || `exit ${result.code}`}`);
114
+ }
115
+ catch (error) {
116
+ failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ return false;
119
+ };
120
+ const isVolumeRemoved = await step('podman volume rm', ['volume', 'rm', VOLUME_NAME]);
121
+ await step('podman rmi', ['rmi', formatImageTag(record.version)]);
122
+ if (isVolumeRemoved) {
123
+ rmSync(paths.quadletDirectory, { recursive: true, force: true });
124
+ }
125
+ if (failures.length > 0) {
126
+ throw new Error(`purge incomplete: ${failures.join('; ')}`);
127
+ }
128
+ },
129
+ async restart() {
130
+ const result = await systemctl('restart', SERVICE_NAME);
131
+ if (result.code !== 0) {
132
+ throw new Error(`systemctl restart failed (exit ${result.code})`);
133
+ }
134
+ },
135
+ async clearOwner(record) {
136
+ removeEnvKey(envPath, 'OWNER_ID');
137
+ try {
138
+ const result = await exec('podman', ['exec', SERVICE_NAME, SERVICE_NAME, 'owner', 'clear']);
139
+ if (result.code === 0) {
140
+ return;
141
+ }
142
+ }
143
+ catch {
144
+ // fall through to the one-off container
145
+ }
146
+ const result = await exec('podman', [
147
+ 'run',
148
+ '--rm',
149
+ '-v',
150
+ `${VOLUME_NAME}:/data`,
151
+ formatImageTag(record.version),
152
+ SERVICE_NAME,
153
+ 'owner',
154
+ 'clear',
155
+ ]);
156
+ if (result.code !== 0) {
157
+ throw new Error(`couldn't clear the owner in the container: ${result.stderr.trim()}`);
158
+ }
159
+ },
160
+ async isRunning() {
161
+ return (await systemctl('is-active', '--quiet', SERVICE_NAME)).code === 0;
162
+ },
163
+ logsHint() {
164
+ return `journalctl --user -u ${SERVICE_NAME} -f`;
165
+ },
166
+ };
167
+ }