@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/README.md +276 -2
- package/bin/dshnc.mjs +337 -0
- package/lib/api.mjs +177 -0
- package/lib/approver.mjs +138 -0
- package/lib/ask.mjs +17 -0
- package/lib/autostart.mjs +311 -0
- package/lib/bash.mjs +168 -0
- package/lib/config.mjs +103 -0
- package/lib/i18n.mjs +511 -0
- package/lib/jobs.mjs +203 -0
- package/lib/onboard.mjs +199 -0
- package/lib/ping-policy.mjs +49 -0
- package/lib/plugins/files.mjs +327 -0
- package/lib/plugins/index.mjs +116 -0
- package/lib/plugins/job.mjs +111 -0
- package/lib/plugins/session.mjs +192 -0
- package/lib/plugins/shell.mjs +71 -0
- package/lib/presence.mjs +171 -0
- package/lib/runner.mjs +600 -0
- package/lib/sandbox.mjs +152 -0
- package/lib/sessions.mjs +148 -0
- package/lib/shell.mjs +154 -0
- package/lib/store.mjs +436 -0
- package/package.json +45 -4
package/lib/jobs.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Долгоиграющие процессы: запустил, получил id, читаешь вывод по смещению.
|
|
3
|
+
*
|
|
4
|
+
* Отдельно от `exec`, потому что контракт другой: `exec` отвечает результатом,
|
|
5
|
+
* а здесь ответ — идентификатор, и дальше идут `output`, `kill`, `list`.
|
|
6
|
+
* Смешивать это в одном методе пришлось бы аргументами вроде `background`,
|
|
7
|
+
* половина которых бессмысленна в другом режиме.
|
|
8
|
+
*
|
|
9
|
+
* Модель повторяет поведение jobs в DSH: статус (`running`/`stopping`/
|
|
10
|
+
* `completed`/`killed`/`failed`), кольцевой буфер вывода и убийство всей
|
|
11
|
+
* группы процессов, а не одного pid.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-node-client/jobs
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
import { commandArgv, killTree, spawnCommand, platform } from './shell.mjs'
|
|
19
|
+
|
|
20
|
+
/** Сколько байт вывода держим на job: старое вытесняется. */
|
|
21
|
+
const RING_BYTES = 256 * 1024
|
|
22
|
+
/** Потолок одновременных job: иначе узел завалит сам себя. */
|
|
23
|
+
const MAX_JOBS = 8
|
|
24
|
+
/** Сколько завершённых помним: по ним смотрят, что было, но не бесконечно. */
|
|
25
|
+
const MAX_FINISHED = 32
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Реестр фоновых процессов клиента.
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {number} [options.ringBytes]
|
|
31
|
+
* @param {number} [options.maxJobs]
|
|
32
|
+
*/
|
|
33
|
+
export function createJobRegistry ({ ringBytes = RING_BYTES, maxJobs = MAX_JOBS } = {}) {
|
|
34
|
+
/** @type {Map<string, object>} */
|
|
35
|
+
const jobs = new Map()
|
|
36
|
+
|
|
37
|
+
/** Убрать старые завершённые: демон живёт неделями, память не резиновая. */
|
|
38
|
+
function evictFinished () {
|
|
39
|
+
const finished = [...jobs.values()]
|
|
40
|
+
.filter((job) => job.status !== 'running' && job.status !== 'stopping')
|
|
41
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))
|
|
42
|
+
while (finished.length > MAX_FINISHED) {
|
|
43
|
+
const job = finished.shift()
|
|
44
|
+
jobs.delete(job.id)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function running () {
|
|
49
|
+
let count = 0
|
|
50
|
+
for (const job of jobs.values()) if (job.status === 'running' || job.status === 'stopping') count++
|
|
51
|
+
return count
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Запустить команду в фоне.
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {string} options.command
|
|
58
|
+
* @param {string} [options.cwd]
|
|
59
|
+
* @param {string} [options.shell]
|
|
60
|
+
* @param {number} [options.timeout] секунды; 0 или пусто — без ограничения
|
|
61
|
+
* @returns {{ok: boolean, results?: object[], code?: string, message?: string}}
|
|
62
|
+
*/
|
|
63
|
+
function start ({ command, cwd, shell, timeout, sandbox = null }) {
|
|
64
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
65
|
+
return { ok: false, code: 'bad_args', message: 'нужна команда' }
|
|
66
|
+
}
|
|
67
|
+
if (running() >= maxJobs) {
|
|
68
|
+
return { ok: false, code: 'too_many_jobs', message: `уже работает ${running()} процессов, потолок ${maxJobs}` }
|
|
69
|
+
}
|
|
70
|
+
const id = `job-${randomBytes(8).toString('hex')}`
|
|
71
|
+
// Задание идёт тем же путём, что и обычная команда: если политика узла
|
|
72
|
+
// требует песочницу, процесс запускается в ней. Иначе фоновый процесс был бы
|
|
73
|
+
// способом обойти политику одной строкой.
|
|
74
|
+
const prepared = commandArgv({ command, shell, sandbox })
|
|
75
|
+
if (prepared.error) return prepared.error
|
|
76
|
+
const child = spawn(prepared.argv[0], prepared.argv.slice(1), {
|
|
77
|
+
cwd: prepared.enforcement === 'bwrap' ? undefined : (cwd || process.cwd()),
|
|
78
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
79
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
+
detached: platform !== 'win32',
|
|
81
|
+
})
|
|
82
|
+
const job = {
|
|
83
|
+
id,
|
|
84
|
+
command,
|
|
85
|
+
cwd: cwd ?? null,
|
|
86
|
+
status: 'running',
|
|
87
|
+
sandbox: prepared.enforcement,
|
|
88
|
+
startedAt: Date.now(),
|
|
89
|
+
endedAt: null,
|
|
90
|
+
code: null,
|
|
91
|
+
chunks: [],
|
|
92
|
+
bytes: 0,
|
|
93
|
+
dropped: 0,
|
|
94
|
+
cursor: 0,
|
|
95
|
+
child,
|
|
96
|
+
timer: null,
|
|
97
|
+
}
|
|
98
|
+
const push = (type, text) => {
|
|
99
|
+
if (!text) return
|
|
100
|
+
const chunk = { at: job.bytes, type, text }
|
|
101
|
+
job.chunks.push(chunk)
|
|
102
|
+
job.bytes += Buffer.byteLength(text)
|
|
103
|
+
// вытесняем старое, но так, чтобы смещения оставались абсолютными
|
|
104
|
+
while (job.chunks.length > 1 && job.bytes - job.chunks[0].at > ringBytes) {
|
|
105
|
+
const removed = job.chunks.shift()
|
|
106
|
+
job.dropped += Buffer.byteLength(removed.text)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
child.stdout?.on('data', (chunk) => push('stdout', String(chunk)))
|
|
110
|
+
child.stderr?.on('data', (chunk) => push('stderr', String(chunk)))
|
|
111
|
+
child.on('error', (error) => {
|
|
112
|
+
job.status = 'failed'
|
|
113
|
+
job.message = error.message
|
|
114
|
+
job.endedAt = Date.now()
|
|
115
|
+
})
|
|
116
|
+
child.on('close', (code, signal) => {
|
|
117
|
+
if (job.timer) clearTimeout(job.timer)
|
|
118
|
+
if (job.status === 'stopping') job.status = 'killed'
|
|
119
|
+
else if (job.status !== 'failed') job.status = 'completed'
|
|
120
|
+
job.code = code
|
|
121
|
+
job.signal = signal ?? null
|
|
122
|
+
job.endedAt = Date.now()
|
|
123
|
+
evictFinished()
|
|
124
|
+
})
|
|
125
|
+
if (Number(timeout) > 0) {
|
|
126
|
+
job.timer = setTimeout(() => killTree(child, 'SIGKILL'), Number(timeout) * 1000)
|
|
127
|
+
if (typeof job.timer.unref === 'function') job.timer.unref()
|
|
128
|
+
}
|
|
129
|
+
jobs.set(id, job)
|
|
130
|
+
return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status }) }] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Прочитать вывод начиная с абсолютного смещения.
|
|
135
|
+
* @param {string} id
|
|
136
|
+
* @param {number} [offset] по умолчанию — с того места, где модель остановилась
|
|
137
|
+
* @returns {{ok: boolean, results?: object[], code?: string, message?: string}}
|
|
138
|
+
*/
|
|
139
|
+
function output (id, offset) {
|
|
140
|
+
const job = jobs.get(id)
|
|
141
|
+
if (!job) return { ok: false, code: 'no_such_job', message: `нет процесса ${id}` }
|
|
142
|
+
const from = Number.isFinite(Number(offset)) ? Number(offset) : job.cursor
|
|
143
|
+
const parts = job.chunks.filter((chunk) => chunk.at >= from)
|
|
144
|
+
const text = parts.map((chunk) => chunk.text).join('')
|
|
145
|
+
job.cursor = job.bytes
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
results: [{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: JSON.stringify({
|
|
151
|
+
id: job.id,
|
|
152
|
+
status: job.status,
|
|
153
|
+
code: job.code ?? null,
|
|
154
|
+
signal: job.signal ?? null,
|
|
155
|
+
offset: job.bytes,
|
|
156
|
+
dropped: job.dropped,
|
|
157
|
+
tookMs: (job.endedAt ?? Date.now()) - job.startedAt,
|
|
158
|
+
sandbox: job.sandbox ?? 'none',
|
|
159
|
+
stdout: parts.filter((chunk) => chunk.type === 'stdout').map((chunk) => chunk.text).join(''),
|
|
160
|
+
stderr: parts.filter((chunk) => chunk.type === 'stderr').map((chunk) => chunk.text).join(''),
|
|
161
|
+
}),
|
|
162
|
+
}],
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Попросить процесс завершиться, всей группой. */
|
|
167
|
+
function kill (id, signal = 'SIGTERM') {
|
|
168
|
+
const job = jobs.get(id)
|
|
169
|
+
if (!job) return { ok: false, code: 'no_such_job', message: `нет процесса ${id}` }
|
|
170
|
+
if (job.status !== 'running') {
|
|
171
|
+
return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status, changed: false }) }] }
|
|
172
|
+
}
|
|
173
|
+
job.status = 'stopping'
|
|
174
|
+
killTree(job.child, signal)
|
|
175
|
+
return { ok: true, results: [{ type: 'text', text: JSON.stringify({ id, status: job.status, signal }) }] }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Что сейчас запущено и чем кончилось (последние завершённые тоже видны). */
|
|
179
|
+
function list () {
|
|
180
|
+
const items = [...jobs.values()].map((job) => ({
|
|
181
|
+
id: job.id,
|
|
182
|
+
status: job.status,
|
|
183
|
+
code: job.code ?? null,
|
|
184
|
+
command: job.command.slice(0, 200),
|
|
185
|
+
sandbox: job.sandbox,
|
|
186
|
+
bytes: job.bytes,
|
|
187
|
+
tookMs: (job.endedAt ?? Date.now()) - job.startedAt,
|
|
188
|
+
}))
|
|
189
|
+
return { ok: true, results: [{ type: 'text', text: JSON.stringify({ jobs: items }) }] }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Убить всё: нужно при остановке демона, иначе процессы переживут его. */
|
|
193
|
+
function stopAll () {
|
|
194
|
+
for (const job of jobs.values()) {
|
|
195
|
+
if (job.status === 'running' || job.status === 'stopping') {
|
|
196
|
+
job.status = 'stopping'
|
|
197
|
+
killTree(job.child, 'SIGTERM')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { start, output, kill, list, stopAll, get size () { return jobs.size } }
|
|
203
|
+
}
|
package/lib/onboard.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Мастер первичной настройки: `dshnc onboard`.
|
|
3
|
+
*
|
|
4
|
+
* Меню, а не поток вопросов, потому что настраивать приходится разное и в
|
|
5
|
+
* разном порядке: кто-то пришёл за политикой доступа, кто-то за автозапуском.
|
|
6
|
+
*
|
|
7
|
+
* Главное правило: **текущее значение — предложение по умолчанию**, а пустой
|
|
8
|
+
* ответ его сохраняет. Так мастер не ломает уже настроенный узел и не требует
|
|
9
|
+
* вспоминать, что там было.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-node-client/onboard
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Взять правила того сервера, к которому узел подключён.
|
|
16
|
+
*
|
|
17
|
+
* Политика хранится по адресам серверов, поэтому «текущий потолок» — это не
|
|
18
|
+
* `config.policy`, а `config.policy[url]`. Если серверов несколько, берём
|
|
19
|
+
* первый: мастер правит один узел за раз, а переносить правила между серверами
|
|
20
|
+
* он не умеет и не должен делать вид, что умеет.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} config настройки клиента
|
|
23
|
+
* @returns {{url: string|null, policy: object}}
|
|
24
|
+
*/
|
|
25
|
+
export function pickPolicy (config) {
|
|
26
|
+
const entries = Object.entries(config?.policy ?? {})
|
|
27
|
+
if (!entries.length) return { url: null, policy: {} }
|
|
28
|
+
const [url, policy] = entries[0]
|
|
29
|
+
return { url, policy: policy ?? {} }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Ответ с подсказкой: пустой ввод оставляет прежнее значение. */
|
|
33
|
+
async function askDefault (ask, question, current) {
|
|
34
|
+
const shown = current === null || current === undefined || current === ''
|
|
35
|
+
? question
|
|
36
|
+
: `${question} [${current}]`
|
|
37
|
+
const answer = (await ask(`${shown}: `) ?? '').trim()
|
|
38
|
+
return answer === '' ? current : answer
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Список через запятую ↔ массив. */
|
|
42
|
+
function toList (value) {
|
|
43
|
+
if (Array.isArray(value)) return value
|
|
44
|
+
return String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Пункт меню «доступ»: имя, каталоги, режимы, кто отвечает на вопросы.
|
|
49
|
+
*
|
|
50
|
+
* @returns {Promise<object>} изменения, которые надо сохранить
|
|
51
|
+
*/
|
|
52
|
+
export async function askAccess ({ ask, print = () => {}, config, policy, url = null }) {
|
|
53
|
+
const patch = {}
|
|
54
|
+
// Мастер не просто спрашивает — он объясняет, что означает каждый ответ.
|
|
55
|
+
// Без этого человек отвечает «наугад», а потом не понимает, почему узел
|
|
56
|
+
// отказывает в записи или не задаёт вопросы.
|
|
57
|
+
print('Имя узла — это то, чем узел зовут в вызовах и что видно в админке.')
|
|
58
|
+
patch.name = await askDefault(ask, 'Имя узла', config.name ?? '')
|
|
59
|
+
patch.aliases = toList(await askDefault(ask, 'Алиасы через запятую', toList(config.aliases).join(', ')))
|
|
60
|
+
print('Каталог сессий — где клиент заводит рабочие каталоги для моделей.')
|
|
61
|
+
// каталоги клиент читает из политики сервера, если они там заданы, поэтому
|
|
62
|
+
// по умолчанию подставляем именно их, а не пустой корень конфига
|
|
63
|
+
const workdir = await askDefault(ask, 'Каталог сессий (абсолютный путь)', policy?.workdir ?? config.workdir ?? '')
|
|
64
|
+
print('Разрешённые каталоги — границы записи: всё вне них узлу недоступно на запись.')
|
|
65
|
+
const roots = await askDefault(
|
|
66
|
+
ask,
|
|
67
|
+
'Разрешённые каталоги через запятую',
|
|
68
|
+
toList(policy?.roots ?? config.roots).join(', '),
|
|
69
|
+
)
|
|
70
|
+
const modes = ['ro', 'rw', 'full']
|
|
71
|
+
print('Потолок прав — самый широкий режим, который узел разрешит вообще:')
|
|
72
|
+
print(' ro — только чтение, rw — запись внутри разрешённых каталогов, full — без песочницы.')
|
|
73
|
+
const max = await askDefault(ask, `Потолок прав (${modes.join('/')})`, policy?.max ?? 'rw')
|
|
74
|
+
print('Стартовый режим — в каком режиме команда запускается по умолчанию.')
|
|
75
|
+
print(' Он может быть уже потолка: тогда расширение требует разрешения человека.')
|
|
76
|
+
const base = await askDefault(ask, `Стартовый режим (${modes.join('/')})`, policy?.base ?? 'rw')
|
|
77
|
+
print('Кто отвечает на вопрос «разрешить шире?»:')
|
|
78
|
+
print(' local — человек за этой машиной (окно на узле), server — человек в DSH, none — не спрашивать.')
|
|
79
|
+
const approver = await askDefault(ask, 'Кто отвечает на вопросы: local/server/none', policy?.approver ?? 'none')
|
|
80
|
+
|
|
81
|
+
// и каталоги, и режимы пишем в раздел этого сервера: раннер собирает общие
|
|
82
|
+
// значения из корня и накрывает их политикой сервера, поэтому корень без
|
|
83
|
+
// политики выглядел бы «настроил и не работает»
|
|
84
|
+
const next = { max, base, approver, roots: toList(roots), workdir }
|
|
85
|
+
if (approver === 'local') {
|
|
86
|
+
const current = policy?.ask?.local?.tool?.command ?? ''
|
|
87
|
+
print('Команда вопроса — чем на этой машине спросить человека. Подойдёт любой')
|
|
88
|
+
print(' инструмент, который возвращает 0 на «да»:')
|
|
89
|
+
print(' zenity --text-info --width=600 --title=dsh-nodes')
|
|
90
|
+
print(' kdialog --textinputbox "dsh-nodes"')
|
|
91
|
+
print(' notify-send ... && свой скрипт, который ждёт ответа')
|
|
92
|
+
print(' Текст вопроса уходит в stdin, если в команде нет %s.')
|
|
93
|
+
const tool = await askDefault(ask, 'Команда вопроса (пусто — не спрашивать)', current)
|
|
94
|
+
next.ask = tool
|
|
95
|
+
? { local: { enabled: true, tool: { type: 'external', command: tool } } }
|
|
96
|
+
: { local: { enabled: false } }
|
|
97
|
+
}
|
|
98
|
+
// правила пишем в тот сервер, который настраиваем: иначе они окажутся нигде
|
|
99
|
+
patch.policy = url
|
|
100
|
+
? { ...(config.policy ?? {}), [url]: { ...(config.policy?.[url] ?? {}), ...next } }
|
|
101
|
+
: (config.policy ?? {})
|
|
102
|
+
return patch
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Пункт меню «связь»: опрос и пинг по сокету. */
|
|
106
|
+
export async function askConnection ({ ask, print = () => {}, config }) {
|
|
107
|
+
// Для новой настройки предлагаем рабочие значения: сокет, умерший молча,
|
|
108
|
+
// иначе замечается только по TCP. Ноль — это осознанный выбор «выключить»,
|
|
109
|
+
// и он сохраняется как есть, а не подменяется умолчанием.
|
|
110
|
+
const hasPing = config.pingInterval !== undefined
|
|
111
|
+
print('Периодический опрос — страховка, если сокет не поднялся. Ноль выключает.')
|
|
112
|
+
print('Пинг по сокету — проверка «сервер слышит меня»: 15 с и растяжение до 10 раз,')
|
|
113
|
+
print(' когда сервер давно ничего не присылал. Ноль выключает пинг совсем.')
|
|
114
|
+
return {
|
|
115
|
+
pollIdleMs: Number(await askDefault(ask, 'Периодический опрос, мс (0 — только события)', config.pollIdleMs ?? 0)) || 0,
|
|
116
|
+
pingInterval: Number(await askDefault(ask, 'Пинг по сокету, с (0 — выключен)', config.pingInterval ?? (hasPing ? 0 : 15))) || 0,
|
|
117
|
+
maxPingIntervalFactor: Number(await askDefault(ask, 'Во сколько раз растягивать пинг без работы (0 — не растягивать)', config.maxPingIntervalFactor ?? (hasPing ? 0 : 10))) || 0,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Показать, что получилось: перед записью человек должен увидеть итог. */
|
|
122
|
+
export function renderConfig (config) {
|
|
123
|
+
const plain = { ...config }
|
|
124
|
+
delete plain.servers
|
|
125
|
+
delete plain.id
|
|
126
|
+
return JSON.stringify(plain, null, 2)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Цикл меню.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} options
|
|
133
|
+
* @param {(question: string) => Promise<string>} options.ask
|
|
134
|
+
* @param {(text: string) => void} options.print
|
|
135
|
+
* @param {() => object} options.load свежие настройки
|
|
136
|
+
* @param {(patch: object) => object} options.save сохранить и вернуть итог
|
|
137
|
+
* @param {(config: object) => Promise<void>} options.autostart запуск мастера автозапуска
|
|
138
|
+
* @param {() => string[]} [options.status] строки состояния для шапки
|
|
139
|
+
*/
|
|
140
|
+
export async function runOnboard ({ ask, print, load, save, autostart, status = () => [] }) {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const config = load()
|
|
143
|
+
print('')
|
|
144
|
+
for (const line of status(config)) print(line)
|
|
145
|
+
print('')
|
|
146
|
+
print('Мастер настройки: спрашивает по одному разделу и объясняет, что к чему.')
|
|
147
|
+
print('Текущее значение стоит в скобках; пустой ответ его не меняет.')
|
|
148
|
+
print('')
|
|
149
|
+
print('1) доступ: имя, каталоги, режимы, кто отвечает на вопросы')
|
|
150
|
+
print('2) связь: опрос, пинг по сокету')
|
|
151
|
+
print('3) автозапуск: systemd-юнит')
|
|
152
|
+
print('4) показать конфиг')
|
|
153
|
+
print('0) выход')
|
|
154
|
+
const choice = (await ask('Что настроить? ') ?? '').trim()
|
|
155
|
+
if (choice === '0' || choice === '') return
|
|
156
|
+
|
|
157
|
+
if (choice === '1') {
|
|
158
|
+
const { url, policy } = pickPolicy(config)
|
|
159
|
+
if (!url) {
|
|
160
|
+
print('Сначала подключись к серверу: dshnc connect <ссылка>. Без сервера')
|
|
161
|
+
print('правила доступа писать некуда — они живут в разделе этого сервера.')
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
print(`Правила для ${url}`)
|
|
165
|
+
const patch = await askAccess({ ask, print, config, policy, url })
|
|
166
|
+
// показываем итог ДО записи: предпросмотр не должен ничего сохранять
|
|
167
|
+
print('')
|
|
168
|
+
print('Итог:')
|
|
169
|
+
print(renderConfig({ ...config, ...patch }))
|
|
170
|
+
const ok = (await ask('Записать? [Y/n] ') ?? '').trim().toLowerCase()
|
|
171
|
+
if (ok === '' || ok === 'y' || ok === 'да') {
|
|
172
|
+
save(patch)
|
|
173
|
+
print('сохранено')
|
|
174
|
+
} else {
|
|
175
|
+
print('не записано')
|
|
176
|
+
}
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (choice === '2') {
|
|
181
|
+
const patch = await askConnection({ ask, print, config })
|
|
182
|
+
save(patch)
|
|
183
|
+
print('сохранено')
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (choice === '3') {
|
|
188
|
+
await autostart(load())
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (choice === '4') {
|
|
193
|
+
print(renderConfig(load()))
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
print('нет такого пункта')
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Когда клиент пингует сервер по сокету и с какой паузой.
|
|
3
|
+
*
|
|
4
|
+
* Логика целиком в одной чистой функции, чтобы её можно было проверить тестом
|
|
5
|
+
* без сети: расписание — это арифметика, а не побочный эффект.
|
|
6
|
+
*
|
|
7
|
+
* Правила:
|
|
8
|
+
* - пинг отправляется через `pingInterval` после последнего успешного
|
|
9
|
+
* общения (`lastSeen`);
|
|
10
|
+
* - если сервер давно не присылал команд (`lastCommand`), пауза растёт:
|
|
11
|
+
* первые пять интервалов она обычная, потом ×2, ×3 … но не больше
|
|
12
|
+
* `maxPingIntervalFactor`;
|
|
13
|
+
* - ответа нет в течение интервала — это разрыв, нужен реконнект.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-node-client/ping-policy
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** После скольких интервалов без команд пауза начинает расти. */
|
|
19
|
+
export const GROW_AFTER_TICKS = 5
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Во сколько раз растянуть паузу.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {number} options.ticks сколько интервалов прошло без новых команд
|
|
26
|
+
* @param {number} [options.factor] потолок растяжения; 0 или меньше — не растягивать
|
|
27
|
+
* @returns {number} множитель, всегда >= 1
|
|
28
|
+
*/
|
|
29
|
+
export function pingMultiplier ({ ticks, factor = 0 }) {
|
|
30
|
+
const cap = Number(factor) > 0 ? Number(factor) : 1
|
|
31
|
+
if (ticks < GROW_AFTER_TICKS) return 1
|
|
32
|
+
// 5 тиков → ×2, 6 → ×3, и так до потолка
|
|
33
|
+
return Math.min(2 + (ticks - GROW_AFTER_TICKS), cap)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Пауза до следующего пинга в миллисекундах.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {number} options.pingInterval секунды из конфига; 0 или меньше — выключено
|
|
41
|
+
* @param {number} [options.factor] потолок растяжения
|
|
42
|
+
* @param {number} [options.ticks] интервалов без команд
|
|
43
|
+
* @returns {number} миллисекунды; 0 означает «пинг выключен»
|
|
44
|
+
*/
|
|
45
|
+
export function pingDelay ({ pingInterval, factor = 0, ticks = 0 }) {
|
|
46
|
+
const base = Number(pingInterval)
|
|
47
|
+
if (!Number.isFinite(base) || base <= 0) return 0
|
|
48
|
+
return Math.round(base * pingMultiplier({ ticks, factor }) * 1000)
|
|
49
|
+
}
|