@dr-co/dsh-node-client 0.0.0-stage → 0.0.59

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/lib/api.mjs ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Клиентская часть протокола: заявка, объявление, опрос команд, результат.
3
+ *
4
+ * Ничего не знает про CLI — всё принимает явными параметрами, чтобы это можно
5
+ * было тестировать без процессов и терминала.
6
+ *
7
+ * @module dsh-node-client/api
8
+ */
9
+ import { randomUUID } from 'node:crypto'
10
+ import { readFileSync } from 'node:fs'
11
+
12
+ import { t } from './i18n.mjs'
13
+
14
+ /** Версия протокола, которую объявляем серверу (`proto`). */
15
+ export const PROTO = 1
16
+ // Версия берётся из package.json: зашитая строка врала в админке (все узлы
17
+ // показывались как 0.0.1, сколько бы клиент ни обновляли).
18
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
19
+ export const CLIENT_VERSION = `dshnc/${pkg.version}`
20
+ /** Голая версия клиента: `dshnc version` и строка в логе при старте демона. */
21
+ export const VERSION = pkg.version
22
+
23
+ /** Ошибка протокола: HTTP-код + код из закрытого словаря. */
24
+ export class NodeError extends Error {
25
+ constructor (status, code, message) {
26
+ super(message)
27
+ this.status = status
28
+ this.code = code
29
+ }
30
+ }
31
+
32
+ async function request (base, path, { method = 'GET', token, body, timeoutMs = 30000 } = {}) {
33
+ const controller = new AbortController()
34
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
35
+ try {
36
+ const response = await fetch(base + path, {
37
+ method,
38
+ headers: {
39
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
40
+ ...(body ? { 'content-type': 'application/json' } : {}),
41
+ },
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ signal: controller.signal,
44
+ })
45
+ const text = await response.text()
46
+ const payload = text ? JSON.parse(text) : {}
47
+ if (!response.ok) {
48
+ if (response.status === 401) {
49
+ if (process.env.DSHNC_DEBUG) {
50
+ // eslint-disable-next-line no-console
51
+ console.error(`[debug] 401 ${path} token=${String(token ?? '').slice(0, 8)}…`)
52
+ }
53
+ throw new NodeError(401, 'unauthorized', t('api.unauthorized'))
54
+ }
55
+ throw new NodeError(response.status, payload.code ?? 'bad_request', payload.message ?? t('api.http', { status: response.status }))
56
+ }
57
+ return { status: response.status, body: payload }
58
+ } catch (error) {
59
+ if (error instanceof NodeError) throw error
60
+ if (error.name === 'AbortError') throw new NodeError(0, 'timeout', t('api.timeout'))
61
+ throw new NodeError(0, 'network', error.message)
62
+ } finally {
63
+ clearTimeout(timer)
64
+ }
65
+ }
66
+
67
+ /** Заявка на регистрацию (план §9.1). */
68
+ export async function enroll (base, { uuid, name, aliases, nonce }) {
69
+ return request(base, '/v1/enroll', {
70
+ method: 'POST',
71
+ body: { uuid, name, aliases, nonce: nonce ?? null, proto: PROTO, clientVersion: CLIENT_VERSION },
72
+ })
73
+ }
74
+
75
+ /** Опрос заявки: 202 пока ждёт, 200 с токеном после одобрения. */
76
+ export async function enrollStatus (base, uuid, nonce = null) {
77
+ try {
78
+ const query = nonce ? `?nonce=${encodeURIComponent(nonce)}` : ''
79
+ return await request(base, `/v1/enroll/${encodeURIComponent(uuid)}${query}`)
80
+ } catch (error) {
81
+ if (error.status === 202) return { status: 202, body: { status: 'pending' } }
82
+ throw error
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Разобрать ссылку подключения: `.../dsh-nodes/pair?v=<код>`.
88
+ * @param {string} input
89
+ * @returns {{base: string, code: string|null}}
90
+ */
91
+ export function parseConnectUrl (input) {
92
+ const url = new URL(input)
93
+ const code = url.searchParams.get('v') ?? url.searchParams.get('code') ?? null
94
+ const path = url.pathname.replace(/\/+$/, '').replace(/\/(pair||enroll)$/, '')
95
+ url.search = ''
96
+ url.hash = ''
97
+ url.pathname = path || '/dsh-nodes'
98
+ return { base: `${url.origin}${url.pathname}`, code }
99
+ }
100
+
101
+ /** Обменять код подключения на токен: подтверждать в админке нечего. */
102
+ export async function pair (base, { code, uuid, name, aliases }) {
103
+ return request(base, '/v1/pair', {
104
+ method: 'POST',
105
+ body: { code, uuid, name, aliases, proto: PROTO, clientVersion: CLIENT_VERSION },
106
+ })
107
+ }
108
+
109
+ /** Объявить, что умеем. */
110
+ export async function announce (base, token, apis, { workdir = null, access = null, allowEscalation = true, uptimeSeconds = null, approver = null, startMode = null } = {}) {
111
+ return request(base, '/v1/announce', {
112
+ method: 'PUT',
113
+ token,
114
+ // потолок прав и «можно ли спрашивать» нужны серверу, чтобы решать,
115
+ // задавать ли вопрос человеку при отказе песочницы
116
+ body: { apis, proto: PROTO, clientVersion: CLIENT_VERSION, workdir, access, allowEscalation, uptimeSeconds, approver, base: startMode },
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Забрать пачку команд на исполнение.
122
+ *
123
+ * Курсора больше нет: сервер отдаёт с головы очереди и сразу помечает взятое
124
+ * как `processing`, повторно не выдаёт. `limit` — сколько взять за раз.
125
+ */
126
+ export async function commands (base, token, { limit = 5 } = {}) {
127
+ return request(base, `/v1/commands?limit=${limit}`, { token })
128
+ }
129
+
130
+ /** Вернуть результат команды. «Уже закрыта» — только 409. */
131
+ export async function result (base, token, seq, payload) {
132
+ try {
133
+ return await request(base, `/v1/commands/${seq}/result`, { method: 'POST', token, body: payload })
134
+ } catch (error) {
135
+ // 404 — это не «уже закрыта», а «такого маршрута нет»: проглотить его значит
136
+ // молча потерять результат и всё равно сдвинуть курсор
137
+ if (error.status === 409) return { status: 409, body: { ok: false, late: true } }
138
+ throw error
139
+ }
140
+ }
141
+
142
+ /** Счётчики узла: нужны для сверки поколения и наличия работы. */
143
+ export async function state (base, token) {
144
+ return request(base, '/v1/state', { token })
145
+ }
146
+
147
+ /** API, которое умеет клиент из коробки: одна команда bash. */
148
+ export function bashApi ({ shell = '/bin/sh', defaultTimeout = 60 } = {}) {
149
+ return {
150
+ name: 'bash',
151
+ version: '2',
152
+ description: t('bash.api.description'),
153
+ methods: [
154
+ {
155
+ method: 'exec',
156
+ description: t('bash.exec.description'),
157
+ args: {
158
+ type: 'object',
159
+ properties: {
160
+ command: { type: 'string', description: t('bash.arg.command') },
161
+ cwd: { type: 'string', description: t('bash.arg.cwd') },
162
+ timeout: { type: 'number', description: t('bash.arg.timeout') },
163
+ stdin: { type: 'string', description: t('bash.arg.stdin') },
164
+ },
165
+ required: ['command'],
166
+ additionalProperties: false,
167
+ },
168
+ meta: { shell, defaultTimeout },
169
+ },
170
+ ],
171
+ }
172
+ }
173
+
174
+ /** Свежий uuid на случай, если в конфиге его нет. */
175
+ export function newUuid () {
176
+ return randomUUID()
177
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Локальный апрув: спросить человека за этой машиной.
3
+ *
4
+ * Работает через внешнюю команду из настроек узла — `zenity`, `notify-send`,
5
+ * `powershell`, что угодно. Отдельный модуль, потому что тут легко ошибиться:
6
+ * текст вопроса приходит извне (его собрал сервер из команды модели), и
7
+ * подставлять его прямо в командную строку нельзя — это была бы инъекция.
8
+ *
9
+ * Поэтому: `%s` в шаблоне заменяется на экранированный текст, а полный JSON
10
+ * уходит на stdin. Команда запускается без оболочки, списком аргументов.
11
+ *
12
+ * @module dsh-node-client/approver
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+
16
+ /**
17
+ * Аргументы команды апрува.
18
+ *
19
+ * Два способа передать текст:
20
+ * - в шаблоне есть `%s` → подставляем в аргумент (для `zenity --question --text=%s`);
21
+ * - нет `%s` → текст уходит на stdin (для `zenity --text-info`), и тогда длина
22
+ * вопроса ничем не ограничена командной строкой.
23
+ *
24
+ * Разбиение делается до подстановки: иначе пробелы из вопроса разорвали бы
25
+ * аргумент на части. Оболочки здесь нет, поэтому экранировать нечего.
26
+ */
27
+ export function buildArgs (template, prompt) {
28
+ const args = splitArgs(template)
29
+ if (!template.includes('%s')) return args
30
+ return args.map((arg) => arg.replaceAll('%s', String(prompt)))
31
+ }
32
+
33
+ /** Пойдёт ли текст в аргументы или на stdin. */
34
+ export function usesStdin (template) {
35
+ return !String(template).includes('%s')
36
+ }
37
+
38
+ /** Разбить строку на аргументы, уважая кавычки. */
39
+ export function splitArgs (template) {
40
+ const args = []
41
+ let current = ''
42
+ let quote = null
43
+ for (const char of template) {
44
+ if (quote) {
45
+ if (char === quote) quote = null
46
+ else current += char
47
+ continue
48
+ }
49
+ if (char === '"' || char === "'") {
50
+ quote = char
51
+ continue
52
+ }
53
+ if (/\s/.test(char)) {
54
+ if (current) args.push(current)
55
+ current = ''
56
+ continue
57
+ }
58
+ current += char
59
+ }
60
+ if (current) args.push(current)
61
+ return args
62
+ }
63
+
64
+ /**
65
+ * Спросить человека.
66
+ *
67
+ * `ok` команды = «разрешаю»: инструмент задаёт вопрос так, что код 0 означает
68
+ * согласие (`zenity --question` возвращает 0 на «Yes»), ненулевой — отказ или
69
+ * закрытое окно. Это же поведение у `notify-send` с действиями не получится,
70
+ * поэтому для таких случаев нужен свой скрипт — конфиг это позволяет.
71
+ *
72
+ * @param {object} options
73
+ * @param {string} options.tool команда из настроек
74
+ * @param {string} options.prompt текст вопроса
75
+ * @param {string} options.payload JSON для stdin
76
+ * @param {AbortSignal} [options.signal]
77
+ * @param {number} [options.timeout] секунды
78
+ * @returns {Promise<boolean>} true — разрешено
79
+ */
80
+ export function askLocally ({ tool, prompt, payload, signal, timeout = 86400 }) {
81
+ return new Promise((resolve, reject) => {
82
+ const argv = buildArgs(tool, prompt)
83
+ if (!argv.length) {
84
+ reject(Object.assign(new Error('пустая команда апрува'), { code: 'no_local_approver' }))
85
+ return
86
+ }
87
+ // структурированные данные — в окружение, человеку — текст на stdin
88
+ const env = {
89
+ ...process.env,
90
+ DSHNC_APPROVAL_ID: String(payload?.id ?? ''),
91
+ DSHNC_APPROVAL_MODE: String(payload?.mode ?? ''),
92
+ // в рамках чего спрашиваем: сессия и вопрос (корневой вызов хода)
93
+ DSHNC_SESSION: String(payload?.session ?? ''),
94
+ DSHNC_QUESTION: String(payload?.question?.rootCallId ?? ''),
95
+ DSHNC_CALL: String(payload?.question?.callId ?? ''),
96
+ }
97
+ const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'ignore', 'pipe'], env })
98
+ let stderr = ''
99
+ let settled = false
100
+ const finish = (fn, value) => {
101
+ if (settled) return
102
+ settled = true
103
+ clearTimeout(timer)
104
+ signal?.removeEventListener?.('abort', onAbort)
105
+ fn(value)
106
+ }
107
+ const onAbort = () => {
108
+ try {
109
+ child.kill('SIGTERM')
110
+ } catch {}
111
+ finish(reject, Object.assign(new Error('апрув отменён'), { code: 'cancelled' }))
112
+ }
113
+ const timer = setTimeout(() => {
114
+ try {
115
+ child.kill('SIGTERM')
116
+ } catch {}
117
+ finish(reject, Object.assign(new Error('апрув истёк'), { code: 'approve_timeout' }))
118
+ }, Math.max(1, timeout) * 1000)
119
+ if (typeof timer.unref === 'function') timer.unref()
120
+ signal?.addEventListener?.('abort', onAbort, { once: true })
121
+
122
+ child.stderr?.on('data', (chunk) => {
123
+ if (stderr.length < 2000) stderr += chunk
124
+ })
125
+ child.on('error', (error) => finish(reject, Object.assign(error, { code: 'approve_failed' })))
126
+ child.on('close', (code) => {
127
+ if (code === 0) finish(resolve, true)
128
+ else finish(resolve, false)
129
+ void stderr
130
+ })
131
+ // инструмент может не читать stdin и выйти сразу — это не ошибка
132
+ child.stdin.on('error', () => {})
133
+ try {
134
+ // диалогу нужен человеческий текст; JSON отдаём переменными окружения
135
+ child.stdin.end(usesStdin(tool) ? String(prompt) : String(payload))
136
+ } catch {}
137
+ })
138
+ }
package/lib/ask.mjs ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Подпись вопроса для интерактивного ввода.
3
+ *
4
+ * Значение по умолчанию показывается в квадратных скобках — но только если оно
5
+ * уже не показано в самом тексте вопроса. Иначе получается «...[user] [user]»,
6
+ * что и случилось на первом запуске мастера.
7
+ *
8
+ * @param {string} question текст вопроса
9
+ * @param {string} [fallback] значение по умолчанию
10
+ * @returns {string} строка с двоеточием на конце
11
+ */
12
+ export function formatQuestion (question, fallback) {
13
+ const text = String(question ?? '').trim()
14
+ const hasBracketDefault = /\[[^\]]*\]\s*$/.test(text)
15
+ const suffix = fallback && !hasBracketDefault ? ` [${fallback}]` : ''
16
+ return `${text}${suffix}: `
17
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * `dshnc autostart`: вопросы, юнит systemd, установка.
3
+ *
4
+ * Юнит собирается отдельной функцией, потому что ошибиться в systemd легко и
5
+ * молча: не тот target, забытый `User=`, запуск в графической сессии без
6
+ * переменных окружения. Текст юнита проверяется тестами, а не глазами.
7
+ *
8
+ * @module dsh-node-client/autostart
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+ import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ import { t } from './i18n.mjs'
16
+
17
+ /** Где живёт user-юнит и системный юнит. */
18
+ export const USER_UNIT_DIR = join(homedir(), '.config', 'systemd', 'user')
19
+ export const SYSTEM_UNIT_DIR = '/etc/systemd/system'
20
+
21
+ /**
22
+ * Текст юнита.
23
+ * @param {object} options
24
+ * @param {'user'|'system'} options.scope
25
+ * @param {string} options.execStart абсолютный путь к dshnc
26
+ * @param {string[]} [options.args] аргументы (по умолчанию `daemon`)
27
+ * @param {string} [options.user] `User=` для системного юнита
28
+ * @param {string} [options.home] домашний каталог (для `Environment=HOME=`)
29
+ * @param {boolean} [options.graphical] стартовать внутри графической сессии
30
+ * @param {string} [options.shell] DSHNC_SHELL
31
+ * @param {number} [options.restartSec]
32
+ * @returns {string}
33
+ */
34
+ export function renderUnit ({ scope, execStart, args = ['daemon'], user, home, graphical = false, shell = '/bin/bash', restartSec = 5, pathEnv }) {
35
+ const lines = ['[Unit]', `Description=${t('unit.description')}`]
36
+ if (scope === 'system') {
37
+ lines.push('After=network-online.target', 'Wants=network-online.target')
38
+ } else if (graphical) {
39
+ // запускаемся вместе с графической сессией и забираем её окружение
40
+ lines.push('After=graphical-session.target', 'PartOf=graphical-session.target', 'Wants=graphical-session.target')
41
+ } else {
42
+ lines.push('After=network-online.target', 'Wants=network-online.target')
43
+ }
44
+ lines.push('', '[Service]')
45
+ if (scope === 'system') {
46
+ if (user) lines.push(`User=${user}`)
47
+ if (home) lines.push(`Environment=HOME=${home}`)
48
+ }
49
+ // У юнита свой PATH: node из nvm туда не попадает, и `env: 'node'` падает
50
+ lines.push(`Environment=PATH=${pathEnv ?? '/usr/local/bin:/usr/bin:/bin'}`)
51
+ lines.push(`Environment=DSHNC_SHELL=${shell}`)
52
+ if (scope === 'user' && graphical) {
53
+ // без этого DISPLAY/XAUTHORITY в user-юните пустые
54
+ lines.push("ExecStartPre=-/bin/sh -c 'systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR 2>/dev/null || true'")
55
+ }
56
+ lines.push(
57
+ `ExecStart=${execStart} ${args.join(' ')}`.trim(),
58
+ // перечитать конфиг без разрыва соединений: `systemctl --user reload dshnc`
59
+ 'ExecReload=/bin/kill -HUP $MAINPID',
60
+ 'Restart=always',
61
+ `RestartSec=${restartSec}`,
62
+ '',
63
+ '[Install]',
64
+ `WantedBy=${scope === 'user' && graphical ? 'graphical-session.target' : 'default.target'}`,
65
+ )
66
+ return lines.join('\n') + '\n'
67
+ }
68
+
69
+ /**
70
+ * Чем на самом деле запускать: если нас вызвали как `node …/dshnc.mjs`, то
71
+ * `ExecStart` должен нести интерпретатор и скрипт — на `.mjs` из юнита systemd
72
+ * не запустит по shebang.
73
+ * @param {string} script путь из `process.argv[1]`
74
+ * @param {string} [execPath] путь к node (`process.execPath`)
75
+ */
76
+ export function resolveExecStart (script, execPath = process.execPath) {
77
+ if (!script) return execPath
78
+ return script.endsWith('.mjs') ? `${execPath} ${script}` : script
79
+ }
80
+
81
+ /** Имя файла юнита. */
82
+ export function unitFileName (scope, name = 'dshnc') {
83
+ return `${name}.service`
84
+ }
85
+
86
+ /** Путь к файлу юнита. */
87
+ export function unitPath (scope, name = 'dshnc', dir) {
88
+ const base = dir ?? (scope === 'user' ? USER_UNIT_DIR : SYSTEM_UNIT_DIR)
89
+ return join(base, unitFileName(scope, name))
90
+ }
91
+
92
+ /**
93
+ * Записать юнит.
94
+ * @returns {string} путь
95
+ */
96
+ export function writeUnit ({ scope, name = 'dshnc', text, dir }) {
97
+ const path = unitPath(scope, name, dir)
98
+ mkdirSync(join(path, '..'), { recursive: true })
99
+ writeFileSync(path, text, { mode: 0o644 })
100
+ return path
101
+ }
102
+
103
+ /** Команды, которые надо выполнить после записи файла. */
104
+ export function installCommands ({ scope, name = 'dshnc', graphUser }) {
105
+ if (scope === 'user') {
106
+ const prefix = graphUser ? `sudo -u ${graphUser} -i ` : ''
107
+ return [
108
+ ...(graphUser ? [] : ['systemctl --user daemon-reload']),
109
+ `${prefix}systemctl --user enable --now ${name}`,
110
+ `${prefix}systemctl --user status ${name} --no-pager`,
111
+ ]
112
+ }
113
+ return [
114
+ 'sudo systemctl daemon-reload',
115
+ `sudo systemctl enable --now ${name}`,
116
+ `sudo systemctl status ${name} --no-pager`,
117
+ ]
118
+ }
119
+
120
+ /** Есть ли у нас право создавать системных пользователей без вопросов. */
121
+ export function canCreateUsers ({ uid = process.getuid?.() ?? -1, hasSudo = false } = {}) {
122
+ return uid === 0 || hasSudo
123
+ }
124
+
125
+ /**
126
+ * Создать системного пользователя. Если мы root — сами, если есть sudo — через
127
+ * него, иначе возвращаем false: тогда мастер покажет команду и подождёт.
128
+ * @param {object} options
129
+ * @param {string} options.user
130
+ * @param {string} [options.home]
131
+ * @param {number} [options.uid]
132
+ * @param {boolean} [options.hasSudo]
133
+ * @param {(file: string, args: string[]) => {status: number|null}} [options.run]
134
+ * @returns {boolean}
135
+ */
136
+ export function createUser ({ user, home = `/home/${user}`, uid = process.getuid?.() ?? -1, hasSudo = false, run }) {
137
+ const args = ['--system', '--create-home', '--home-dir', home, '--shell', '/usr/sbin/nologin', user]
138
+ const exec = run ?? ((file, argv) => spawnSync(file, argv, { stdio: 'inherit' }))
139
+ if (uid === 0) return exec('useradd', args).status === 0
140
+ if (hasSudo) return exec('sudo', ['-n', 'useradd', ...args]).status === 0
141
+ return false
142
+ }
143
+
144
+ /**
145
+ * Перенести состояние клиента другому пользователю.
146
+ * @param {object} options
147
+ * @param {string} options.from каталог-источник
148
+ * @param {string} options.to каталог-назначение
149
+ * @param {string} options.user владелец
150
+ * @param {number} [options.uid]
151
+ * @param {boolean} [options.hasSudo]
152
+ * @param {(file: string, args: string[]) => {status: number|null}} [options.run]
153
+ * @returns {boolean}
154
+ */
155
+ export function copyState ({ from, to, user, uid = process.getuid?.() ?? -1, hasSudo = false, run }) {
156
+ const exec = run ?? ((file, argv) => spawnSync(file, argv, { stdio: 'inherit' }))
157
+ try {
158
+ mkdirSync(to, { recursive: true, mode: 0o700 })
159
+ for (const file of readdirSync(from)) {
160
+ if (file === 'node_modules') continue
161
+ const source = join(from, file)
162
+ const target = join(to, file)
163
+ const stat = statSync(source)
164
+ if (stat.isDirectory()) {
165
+ cpSync(source, target, { recursive: true })
166
+ } else {
167
+ copyFileSync(source, target)
168
+ chmodSync(target, file === 'config.json' ? 0o600 : stat.mode & 0o777)
169
+ }
170
+ }
171
+ } catch (error) {
172
+ return false
173
+ }
174
+ const argv = ['-R', `${user}:${user}`, to]
175
+ const result = uid === 0 ? exec('chown', argv) : hasSudo ? exec('sudo', ['-n', 'chown', ...argv]) : { status: 1 }
176
+ return result.status === 0
177
+ }
178
+
179
+ /** Команда создания пользователя, которую можно показать человеку. */
180
+ export function createUserCommand (user, home = `/home/${user}`) {
181
+ return `sudo useradd --system --create-home --home-dir ${home} --shell /usr/sbin/nologin ${user}`
182
+ }
183
+
184
+ /** Существует ли пользователь. */
185
+ export function userExists (user) {
186
+ try {
187
+ return readFileSync('/etc/passwd', 'utf8').split('\n').some((line) => line.startsWith(`${user}:`))
188
+ } catch {
189
+ return false
190
+ }
191
+ }
192
+
193
+ /** Каталог состояния пользователя. */
194
+ export function stateDirFor ({ scope, user, home }) {
195
+ if (scope === 'user' || !user) return join(homedir(), '.dshnc')
196
+ return join(home ?? `/home/${user}`, '.dshnc')
197
+ }
198
+
199
+ /**
200
+ * Мастер: задать вопросы, собрать юнит, записать его, показать команды установки.
201
+ *
202
+ * `ask` и `print` приходят снаружи — поэтому весь диалог проверяется тестом без
203
+ * терминала. Копирование состояния делается только когда для системного юнита
204
+ * создан отдельный пользователь: иначе он просто не найдёт токен.
205
+ *
206
+ * @param {object} options
207
+ * @param {(question: string, fallback?: string) => Promise<string>} options.ask
208
+ * @param {(line: string) => void} options.print
209
+ * @param {object} options.config состояние клиента (name, aliases, servers)
210
+ * @param {string} options.execStart путь к dshnc
211
+ * @param {(scope: string, name: string) => string} [options.unitFile] переопределить путь (тесты)
212
+ * @param {boolean} [options.hasSudo]
213
+ * @param {number} [options.uid]
214
+ */
215
+ export async function runAutostart ({ ask, print, config, execStart, unitFile, hasSudo = false, uid = process.getuid?.() ?? -1, run, stateFrom, stateTo }) {
216
+ print(t('auto.title'))
217
+
218
+ const name = (await ask(t('auto.name'), config.name ?? '')).trim() || config.name
219
+ const aliasesRaw = await ask(t('auto.aliases'), (config.aliases ?? []).join(','))
220
+ const aliases = aliasesRaw.split(',').map((value) => value.trim()).filter(Boolean).slice(0, 8)
221
+
222
+ const scopeAnswer = (await ask(t('auto.scope'), 'user')).trim().toLowerCase()
223
+ const scope = scopeAnswer.startsWith('s') ? 'system' : 'user'
224
+
225
+ let user = null
226
+ let home = null
227
+ let graphical = false
228
+
229
+ if (scope === 'system') {
230
+ const dedicated = (await ask(t('auto.dedicated'), 'n')).trim().toLowerCase().startsWith('y')
231
+ if (dedicated) {
232
+ user = (await ask(t('auto.username'), 'dshnc')).trim() || 'dshnc'
233
+ home = `/home/${user}`
234
+ if (!userExists(user)) {
235
+ const command = createUserCommand(user, home)
236
+ if (canCreateUsers({ uid, hasSudo }) && createUser({ user, home, uid, hasSudo, run })) {
237
+ print(t('auto.userCreated', { user }))
238
+ } else {
239
+ print(t('auto.createUser'))
240
+ print(' ' + command)
241
+ await ask(t('auto.waitingUser', { user }))
242
+ for (let attempt = 0; attempt < 120 && !userExists(user); attempt++) {
243
+ await new Promise((resolve) => setTimeout(resolve, 500))
244
+ }
245
+ }
246
+ }
247
+ }
248
+ } else {
249
+ graphical = (await ask(t('auto.graphical'), 'n')).trim().toLowerCase().startsWith('y')
250
+ }
251
+
252
+ const pathEnv = [dirname(process.execPath), '/usr/local/bin', '/usr/bin', '/bin'].join(':')
253
+ const text = renderUnit({ scope, execStart, user, home: home ?? undefined, graphical, pathEnv, shell: process.env.DSHNC_SHELL ?? '/bin/bash' })
254
+ // проверить, что запускать есть что: иначе юнит уйдёт в цикл падений
255
+ const launcher = execStart.split(' ')[0]
256
+ if (!existsSync(launcher)) {
257
+ print(t('auto.noLauncher', { path: launcher }))
258
+ }
259
+ const path = unitFile ? unitFile(scope, 'dshnc') : unitPath(scope, 'dshnc')
260
+ if (unitFile) {
261
+ mkdirSync(join(path, '..'), { recursive: true })
262
+ writeFileSync(path, text, { mode: 0o644 })
263
+ } else {
264
+ writeUnit({ scope, text })
265
+ }
266
+ print(t('auto.written', { path }))
267
+
268
+ const stateDir = stateDirFor({ scope, user, home })
269
+ print(t('auto.stateDir', { dir: stateDir }))
270
+ let copied = null
271
+ if (scope === 'system' && user) {
272
+ const hasToken = Object.values(config.servers ?? {}).some((entry) => entry?.token)
273
+ if (!hasToken) {
274
+ // демон без токена бесполезен: говорим прямо, что делать
275
+ print(t('auto.noToken', { user }))
276
+ } else if ((await ask(t('auto.copyState', { dir: stateDir }), 'n')).trim().toLowerCase().startsWith('y')) {
277
+ copied = copyState({ from: stateFrom ?? home_of_config(), to: stateTo ?? stateDir, user, uid, hasSudo, run })
278
+ print(copied ? t('auto.copied', { dir: stateTo ?? stateDir }) : t('auto.copyFailed', { user, message: 'chown/copy' }))
279
+ } else {
280
+ print(t('auto.copySkip'))
281
+ }
282
+ }
283
+
284
+ const commands = installCommands({ scope, name: 'dshnc' })
285
+ print('')
286
+ print(t('auto.next'))
287
+ for (const command of commands) print(' ' + command)
288
+ print('')
289
+ print(t('auto.done'))
290
+
291
+ return { name, aliases, scope, user, home, graphical, path, text, commands, stateDir, copied }
292
+ }
293
+
294
+ /** Каталог состояния текущего пользователя. */
295
+ export function home_of_config () {
296
+ return join(homedir(), '.dshnc')
297
+ }
298
+
299
+ /** Применить ответы к конфигу клиента. */
300
+ export function applyAutostartAnswers (config, { name, aliases }) {
301
+ if (name) config.name = name
302
+ config.aliases = aliasArray(aliases)
303
+ return config
304
+ }
305
+
306
+ /** Привести алиасы к массиву строк. */
307
+ export function aliasArray (value) {
308
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean)
309
+ if (typeof value === 'string') return value.split(',').map((item) => item.trim()).filter(Boolean)
310
+ return []
311
+ }