@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Плагин `files`: чтение и запись файлов на узле без запуска оболочки.
|
|
3
|
+
*
|
|
4
|
+
* Смысл именно в том, что это не `shell`: путь проверяется по разрешённым
|
|
5
|
+
* каталогам до любого обращения к диску, операция атомарна, а запись и правка
|
|
6
|
+
* требуют версии файла — если файл изменился между чтением и записью, правка
|
|
7
|
+
* не пройдёт молча. Так же устроен `dsh-fs` в DSH: сервис называет, что можно
|
|
8
|
+
* сделать с файлом, и никогда — как именно он это делает.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-node-client/plugins/files
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
14
|
+
import { dirname, join, relative } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { assertInside, canonicalPath } from '../sessions.mjs'
|
|
17
|
+
import { t } from '../i18n.mjs'
|
|
18
|
+
|
|
19
|
+
/** Сколько байт отдаём за одно чтение, если не просили больше. */
|
|
20
|
+
const READ_LIMIT = 256 * 1024
|
|
21
|
+
|
|
22
|
+
export function api () {
|
|
23
|
+
return {
|
|
24
|
+
name: 'files',
|
|
25
|
+
// контракт вырос: куски для копирования больших файлов
|
|
26
|
+
version: '2',
|
|
27
|
+
description: t('files.api.description'),
|
|
28
|
+
methods: [
|
|
29
|
+
{
|
|
30
|
+
method: 'read',
|
|
31
|
+
description: t('files.read.description'),
|
|
32
|
+
args: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
path: { type: 'string' },
|
|
36
|
+
offset: { type: 'number', description: t('files.arg.offset') },
|
|
37
|
+
limit: { type: 'number', description: t('files.arg.limit') },
|
|
38
|
+
},
|
|
39
|
+
required: ['path'],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
method: 'write',
|
|
45
|
+
description: t('files.write.description'),
|
|
46
|
+
args: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
path: { type: 'string' },
|
|
50
|
+
content: { type: 'string' },
|
|
51
|
+
ifVersion: { type: 'string', description: t('files.arg.version') },
|
|
52
|
+
createDirs: { type: 'boolean', description: t('files.arg.dirs') },
|
|
53
|
+
},
|
|
54
|
+
required: ['path', 'content'],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
method: 'edit',
|
|
60
|
+
description: t('files.edit.description'),
|
|
61
|
+
args: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
path: { type: 'string' },
|
|
65
|
+
old: { type: 'string' },
|
|
66
|
+
new: { type: 'string' },
|
|
67
|
+
replaceAll: { type: 'boolean' },
|
|
68
|
+
ifVersion: { type: 'string', description: t('files.arg.version') },
|
|
69
|
+
},
|
|
70
|
+
required: ['path', 'old', 'new'],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: 'readChunk',
|
|
76
|
+
description: t('files.readChunk.description'),
|
|
77
|
+
args: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
path: { type: 'string' },
|
|
81
|
+
offset: { type: 'number' },
|
|
82
|
+
size: { type: 'number' },
|
|
83
|
+
},
|
|
84
|
+
required: ['path'],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
method: 'writeChunk',
|
|
90
|
+
description: t('files.writeChunk.description'),
|
|
91
|
+
args: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
path: { type: 'string' },
|
|
95
|
+
offset: { type: 'number' },
|
|
96
|
+
data: { type: 'string', description: t('files.arg.base64') },
|
|
97
|
+
final: { type: 'boolean', description: t('files.arg.final') },
|
|
98
|
+
},
|
|
99
|
+
required: ['path', 'data'],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
method: 'sha256',
|
|
105
|
+
description: t('files.sha256.description'),
|
|
106
|
+
args: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: { path: { type: 'string' } },
|
|
109
|
+
required: ['path'],
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
method: 'list',
|
|
115
|
+
description: t('files.list.description'),
|
|
116
|
+
args: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: { path: { type: 'string' } },
|
|
119
|
+
required: ['path'],
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
method: 'stat',
|
|
125
|
+
description: t('files.stat.description'),
|
|
126
|
+
args: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: { path: { type: 'string' } },
|
|
129
|
+
required: ['path'],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Версия файла: размер и время правки. Дешёвая, но достаточная защита. */
|
|
138
|
+
function versionOf (stat) {
|
|
139
|
+
return `${stat.size}-${Math.round(stat.mtimeMs)}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fail (code, message) {
|
|
143
|
+
return { ok: false, code, message }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function text (payload) {
|
|
147
|
+
return { ok: true, results: [{ type: 'text', text: JSON.stringify(payload) }] }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Разрешить путь: только внутри roots, иначе отказ до чтения диска. */
|
|
151
|
+
function resolve (path, ctx) {
|
|
152
|
+
// только явно разрешённые каталоги: если ничего не настроено — отказ, потому
|
|
153
|
+
// что «/» в этом месте означал бы «читай что хочешь»
|
|
154
|
+
const roots = ctx.fileRoots ?? ctx.roots ?? []
|
|
155
|
+
if (!roots.length) {
|
|
156
|
+
throw Object.assign(new Error('каталоги не настроены: задай roots или workdir в конфиге узла'), { code: 'path_denied' })
|
|
157
|
+
}
|
|
158
|
+
return assertInside(path, roots)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function handle (command, ctx = {}) {
|
|
162
|
+
const args = command.args ?? {}
|
|
163
|
+
let target
|
|
164
|
+
try {
|
|
165
|
+
target = resolve(args.path, ctx)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return fail('path_denied', error.message)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
switch (command.method) {
|
|
172
|
+
case 'read': {
|
|
173
|
+
const stat = statSync(target)
|
|
174
|
+
if (stat.isDirectory()) return fail('bad_args', `это каталог: ${target} (используй list)`)
|
|
175
|
+
const offset = Math.max(0, Number(args.offset) || 0)
|
|
176
|
+
const limit = Math.max(1, Number(args.limit) || READ_LIMIT)
|
|
177
|
+
const buffer = readFileSync(target)
|
|
178
|
+
const slice = buffer.subarray(offset, offset + limit)
|
|
179
|
+
return text({
|
|
180
|
+
path: target,
|
|
181
|
+
version: versionOf(stat),
|
|
182
|
+
size: buffer.length,
|
|
183
|
+
offset,
|
|
184
|
+
text: slice.toString('utf8'),
|
|
185
|
+
truncated: offset + slice.length < buffer.length,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
case 'write': {
|
|
189
|
+
// версия проверяется до записи: иначе «правка» молча затрёт чужую
|
|
190
|
+
if (args.ifVersion) {
|
|
191
|
+
let current = null
|
|
192
|
+
try {
|
|
193
|
+
current = versionOf(statSync(target))
|
|
194
|
+
} catch {}
|
|
195
|
+
if (current !== args.ifVersion) {
|
|
196
|
+
return fail('stale', `файл изменился: ожидали версию ${args.ifVersion}, на диске ${current ?? 'нет файла'}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (args.createDirs !== false) mkdirSync(dirname(target), { recursive: true })
|
|
200
|
+
// режим берём у существующего файла: перезапись конфига не должна
|
|
201
|
+
// молча делать его 0600 вместо 0644
|
|
202
|
+
let mode = 0o600
|
|
203
|
+
try {
|
|
204
|
+
mode = statSync(target).mode
|
|
205
|
+
} catch {}
|
|
206
|
+
// атомарно: соседний временный файл и rename, чтобы не оставить половину
|
|
207
|
+
const temp = `${target}.dshnc-${process.pid}-${Date.now()}`
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(temp, args.content, { mode })
|
|
210
|
+
renameSync(temp, target)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
try {
|
|
213
|
+
unlinkSync(temp)
|
|
214
|
+
} catch {}
|
|
215
|
+
throw error
|
|
216
|
+
}
|
|
217
|
+
return text({ path: target, version: versionOf(statSync(target)), bytes: Buffer.byteLength(args.content) })
|
|
218
|
+
}
|
|
219
|
+
case 'edit': {
|
|
220
|
+
const stat = statSync(target)
|
|
221
|
+
const version = versionOf(stat)
|
|
222
|
+
if (args.ifVersion && args.ifVersion !== version) {
|
|
223
|
+
return fail('stale', `файл изменился: ожидали ${args.ifVersion}, на диске ${version}`)
|
|
224
|
+
}
|
|
225
|
+
const original = readFileSync(target, 'utf8')
|
|
226
|
+
const occurrences = original.split(args.old).length - 1
|
|
227
|
+
if (!occurrences) return fail('not_found', `строка не найдена в ${target}`)
|
|
228
|
+
if (occurrences > 1 && args.replaceAll !== true) {
|
|
229
|
+
return fail('ambiguous_edit', `строка встречается ${occurrences} раз; уточни или поставь replaceAll`)
|
|
230
|
+
}
|
|
231
|
+
const updated = args.replaceAll === true
|
|
232
|
+
? original.split(args.old).join(args.new)
|
|
233
|
+
: original.replace(args.old, args.new)
|
|
234
|
+
const temp = `${target}.dshnc-${process.pid}-${Date.now()}`
|
|
235
|
+
writeFileSync(temp, updated, { mode: stat.mode })
|
|
236
|
+
renameSync(temp, target)
|
|
237
|
+
return text({ path: target, version: versionOf(statSync(target)), replaced: args.replaceAll === true ? occurrences : 1 })
|
|
238
|
+
}
|
|
239
|
+
case 'readChunk': {
|
|
240
|
+
// двоичные куски: base64, потому что текст испортит байты вне UTF-8
|
|
241
|
+
const buffer = readFileSync(target)
|
|
242
|
+
const offset = Math.max(0, Number(args.offset) || 0)
|
|
243
|
+
const size = Math.max(1, Number(args.size) || 1024 * 1024)
|
|
244
|
+
const slice = buffer.subarray(offset, offset + size)
|
|
245
|
+
return text({
|
|
246
|
+
path: target,
|
|
247
|
+
offset,
|
|
248
|
+
size: slice.length,
|
|
249
|
+
total: buffer.length,
|
|
250
|
+
eof: offset + slice.length >= buffer.length,
|
|
251
|
+
data: slice.toString('base64'),
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
case 'writeChunk': {
|
|
255
|
+
// пишем в `${path}.inprogress` и переименовываем только последним куском:
|
|
256
|
+
// файл либо появляется целиком, либо не появляется вовсе
|
|
257
|
+
const part = `${target}.inprogress`
|
|
258
|
+
const offset = Math.max(0, Number(args.offset) || 0)
|
|
259
|
+
const chunk = Buffer.from(String(args.data ?? ''), 'base64')
|
|
260
|
+
let fd = null
|
|
261
|
+
try {
|
|
262
|
+
fd = openSync(part, offset === 0 ? 'w' : 'r+')
|
|
263
|
+
} catch {
|
|
264
|
+
if (args.createDirs !== false) mkdirSync(dirname(part), { recursive: true })
|
|
265
|
+
fd = openSync(part, 'w')
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
writeSync(fd, chunk, 0, chunk.length, offset)
|
|
269
|
+
} finally {
|
|
270
|
+
closeSync(fd)
|
|
271
|
+
}
|
|
272
|
+
if (args.final === true) {
|
|
273
|
+
let mode
|
|
274
|
+
try {
|
|
275
|
+
mode = statSync(target).mode
|
|
276
|
+
} catch {}
|
|
277
|
+
if (mode) chmodSync(part, mode & 0o777)
|
|
278
|
+
renameSync(part, target)
|
|
279
|
+
return text({ path: target, bytes: statSync(target).size, version: versionOf(statSync(target)), complete: true })
|
|
280
|
+
}
|
|
281
|
+
return text({ path: target, part, written: chunk.length, offset })
|
|
282
|
+
}
|
|
283
|
+
case 'sha256': {
|
|
284
|
+
const hash = createHash('sha256').update(readFileSync(target)).digest('hex')
|
|
285
|
+
return text({ path: target, sha256: hash, size: statSync(target).size })
|
|
286
|
+
}
|
|
287
|
+
case 'list': {
|
|
288
|
+
const stat = statSync(target)
|
|
289
|
+
if (!stat.isDirectory()) return fail('bad_args', `это файл, а не каталог: ${target}`)
|
|
290
|
+
const entries = readdirSync(target, { withFileTypes: true }).map((entry) => {
|
|
291
|
+
const full = join(target, entry.name)
|
|
292
|
+
let size = null
|
|
293
|
+
let mtime = null
|
|
294
|
+
try {
|
|
295
|
+
const info = statSync(full)
|
|
296
|
+
size = info.size
|
|
297
|
+
mtime = Math.round(info.mtimeMs)
|
|
298
|
+
} catch {}
|
|
299
|
+
return { name: entry.name, type: entry.isDirectory() ? 'dir' : 'file', size, mtime }
|
|
300
|
+
})
|
|
301
|
+
return text({ path: target, entries })
|
|
302
|
+
}
|
|
303
|
+
case 'stat': {
|
|
304
|
+
try {
|
|
305
|
+
const info = statSync(target)
|
|
306
|
+
return text({
|
|
307
|
+
path: target,
|
|
308
|
+
exists: true,
|
|
309
|
+
type: info.isDirectory() ? 'dir' : 'file',
|
|
310
|
+
size: info.size,
|
|
311
|
+
mtime: Math.round(info.mtimeMs),
|
|
312
|
+
version: versionOf(info),
|
|
313
|
+
})
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return fail('path_denied', `нет доступа к ${target}`)
|
|
316
|
+
return text({ path: target, exists: false })
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
default:
|
|
320
|
+
return fail('unknown_method', `files: unknown method ${command.method}`)
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (error.code === 'ENOENT') return fail('not_found', `нет файла ${target}`)
|
|
324
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return fail('path_denied', `нет доступа к ${target}`)
|
|
325
|
+
return fail('internal', error.message)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Загрузка плагинов клиента.
|
|
3
|
+
*
|
|
4
|
+
* Два источника, одинаковый контракт:
|
|
5
|
+
* 1. встроенные — `lib/plugins/*.mjs` в составе пакета (`bash` первый);
|
|
6
|
+
* 2. пользовательские — `~/.dshnc/plugins/<имя>/index.mjs`.
|
|
7
|
+
*
|
|
8
|
+
* Пользовательский плагин — это файл, который экспортирует `api` и `handle`
|
|
9
|
+
* (или `default` с этими полями). Ошибка в одном плагине не должна уносить
|
|
10
|
+
* остальные: он просто выпадает с предупреждением в лог.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-node-client/plugins
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { pathToFileURL } from 'node:url'
|
|
17
|
+
|
|
18
|
+
import { t } from '../i18n.mjs'
|
|
19
|
+
import * as filesPlugin from './files.mjs'
|
|
20
|
+
import * as jobPlugin from './job.mjs'
|
|
21
|
+
import * as sessionPlugin from './session.mjs'
|
|
22
|
+
import * as shellPlugin from './shell.mjs'
|
|
23
|
+
|
|
24
|
+
// `session` — внутренний плагин: он не инструмент, а реконфиг сессии,
|
|
25
|
+
// поэтому едет рядом с bash и всегда первым (bash тоже должен его видеть)
|
|
26
|
+
// `shell` — исполнение команд, `job` — фоновые процессы, `session` — состояние сессии.
|
|
27
|
+
// Старое имя `bash` больше не объявляем: два одинаковых API путали и модель, и
|
|
28
|
+
// админку. Старые клиенты объявляют его сами, сервер к этому готов.
|
|
29
|
+
const BUILTIN = [shellPlugin, jobPlugin, filesPlugin, sessionPlugin]
|
|
30
|
+
|
|
31
|
+
/** Проверить, что модуль похож на плагин. */
|
|
32
|
+
function asPlugin (module, label) {
|
|
33
|
+
const candidate = module?.api ? module : module?.default
|
|
34
|
+
if (candidate && typeof candidate.api === 'function') {
|
|
35
|
+
const factory = candidate.api
|
|
36
|
+
return asPlugin({ ...candidate, api: factory() }, label)
|
|
37
|
+
}
|
|
38
|
+
if (!candidate?.api?.name) throw new Error(t('plugins.noName', { label }))
|
|
39
|
+
if (typeof candidate.api.name !== 'string') throw new Error(t('plugins.nameType', { label }))
|
|
40
|
+
if (!Array.isArray(candidate.api.methods) || !candidate.api.methods.length) {
|
|
41
|
+
throw new Error(t('plugins.noMethods', { label }))
|
|
42
|
+
}
|
|
43
|
+
if (typeof candidate.handle !== 'function') throw new Error(t('plugins.noHandle', { label }))
|
|
44
|
+
for (const method of candidate.api.methods) {
|
|
45
|
+
if (typeof method?.method !== 'string') throw new Error(t('plugins.methodNoName', { label }))
|
|
46
|
+
if (!method.args || typeof method.args !== 'object') throw new Error(t('plugins.methodNoArgs', { label, method: method.method }))
|
|
47
|
+
}
|
|
48
|
+
return { api: candidate.api, handle: candidate.handle, builtin: true, label }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Загрузить плагины.
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {string} options.dir каталог пользовательских плагинов
|
|
55
|
+
* @param {(level: string, message: string) => void} [options.log]
|
|
56
|
+
* @returns {Promise<Array<{api: object, handle: Function}>>}
|
|
57
|
+
*/
|
|
58
|
+
export async function loadPlugins ({ dir, log = () => {} }) {
|
|
59
|
+
const plugins = []
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
|
|
62
|
+
for (const module of BUILTIN) {
|
|
63
|
+
const plugin = asPlugin(module, 'builtin')
|
|
64
|
+
plugin.label = `builtin ${plugin.api.name}`
|
|
65
|
+
plugins.push(plugin)
|
|
66
|
+
seen.add(plugin.api.name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(dir, { recursive: true })
|
|
70
|
+
let entries = []
|
|
71
|
+
try {
|
|
72
|
+
entries = readdirSync(dir)
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log('warn', t('plugins.directory', { message: error.message }))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const entry of entries.sort()) {
|
|
78
|
+
const full = join(dir, entry)
|
|
79
|
+
const file = statSync(full).isDirectory() ? join(full, 'index.mjs') : full
|
|
80
|
+
if (!file.endsWith('.mjs') || !existsSync(file)) continue
|
|
81
|
+
try {
|
|
82
|
+
const module = await import(pathToFileURL(file).href)
|
|
83
|
+
const plugin = asPlugin(module, entry)
|
|
84
|
+
if (seen.has(plugin.api.name)) {
|
|
85
|
+
log('warn', t('plugins.taken', { entry, name: plugin.api.name }))
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
plugin.builtin = false
|
|
89
|
+
plugin.label = `plugin ${entry}`
|
|
90
|
+
plugins.push(plugin)
|
|
91
|
+
seen.add(plugin.api.name)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
log('warn', t('plugins.failed', { entry, message: error.message }))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return plugins
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Список API для `/announce`. */
|
|
101
|
+
export function apisOf (plugins) {
|
|
102
|
+
return plugins.map((plugin) => plugin.api)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Найти плагин для команды. Сначала по номеру API, который выдал сервер,
|
|
107
|
+
* потом — по имени метода (сервер мог перезапуститься и нумерация сменилась).
|
|
108
|
+
*/
|
|
109
|
+
export function resolvePlugin (plugins, command, apiIds = {}) {
|
|
110
|
+
const byId = Object.entries(apiIds).find(([, id]) => id === command.api)
|
|
111
|
+
if (byId) {
|
|
112
|
+
const found = plugins.find((plugin) => plugin.api.name === byId[0])
|
|
113
|
+
if (found) return found
|
|
114
|
+
}
|
|
115
|
+
return plugins.find((plugin) => plugin.api.methods.some((method) => method.method === command.method)) ?? null
|
|
116
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Плагин `job`: долгоиграющие процессы на узле.
|
|
3
|
+
*
|
|
4
|
+
* Отдельный API, а не режим `exec`: у фоновой работы другой контракт — ответ
|
|
5
|
+
* сразу, чтение вывода по смещению, явное убийство группы.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-node-client/plugins/job
|
|
8
|
+
*/
|
|
9
|
+
import { createJobRegistry } from '../jobs.mjs'
|
|
10
|
+
import { t } from '../i18n.mjs'
|
|
11
|
+
|
|
12
|
+
/** Реестр один на клиента: `dshnc daemon` живёт долго, процессов много. */
|
|
13
|
+
let registry = null
|
|
14
|
+
function jobs () {
|
|
15
|
+
registry ??= createJobRegistry()
|
|
16
|
+
return registry
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function api () {
|
|
20
|
+
return {
|
|
21
|
+
name: 'job',
|
|
22
|
+
version: '1',
|
|
23
|
+
description: t('job.api.description'),
|
|
24
|
+
methods: [
|
|
25
|
+
{
|
|
26
|
+
method: 'start',
|
|
27
|
+
description: t('job.start.description'),
|
|
28
|
+
args: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
command: { type: 'string' },
|
|
32
|
+
cwd: { type: 'string' },
|
|
33
|
+
timeout: { type: 'number', description: t('job.arg.timeout') },
|
|
34
|
+
},
|
|
35
|
+
required: ['command'],
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
method: 'output',
|
|
41
|
+
description: t('job.output.description'),
|
|
42
|
+
args: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
id: { type: 'string' },
|
|
46
|
+
offset: { type: 'number', description: t('job.arg.offset') },
|
|
47
|
+
},
|
|
48
|
+
required: ['id'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
method: 'kill',
|
|
54
|
+
description: t('job.kill.description'),
|
|
55
|
+
args: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
id: { type: 'string' },
|
|
59
|
+
signal: { type: 'string', enum: ['SIGTERM', 'SIGKILL'] },
|
|
60
|
+
},
|
|
61
|
+
required: ['id'],
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
method: 'list',
|
|
67
|
+
description: t('job.list.description'),
|
|
68
|
+
args: { type: 'object', properties: {}, additionalProperties: false },
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function handle (command, ctx = {}) {
|
|
75
|
+
const args = command.args ?? {}
|
|
76
|
+
const registry = jobs()
|
|
77
|
+
switch (command.method) {
|
|
78
|
+
case 'start': {
|
|
79
|
+
// каталог: явный → каталог сессии, как у `shell.exec`
|
|
80
|
+
let cwd = args.cwd
|
|
81
|
+
if (!cwd && ctx.sessions && ctx.session) {
|
|
82
|
+
try {
|
|
83
|
+
cwd = ctx.sessions.cwd(ctx.session, { workdir: ctx.workdir, roots: ctx.roots })
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, code: error.code ?? 'bad_session', message: error.message }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return registry.start({
|
|
89
|
+
command: args.command,
|
|
90
|
+
cwd,
|
|
91
|
+
shell: ctx.shell,
|
|
92
|
+
timeout: args.timeout ?? command.timeout,
|
|
93
|
+
// та же песочница, что у exec: политика узла распространяется и на фоновые процессы
|
|
94
|
+
sandbox: ctx.sandbox ?? null,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
case 'output':
|
|
98
|
+
return registry.output(args.id, args.offset)
|
|
99
|
+
case 'kill':
|
|
100
|
+
return registry.kill(args.id, args.signal ?? 'SIGTERM')
|
|
101
|
+
case 'list':
|
|
102
|
+
return registry.list()
|
|
103
|
+
default:
|
|
104
|
+
return { ok: false, code: 'unknown_method', message: `job: unknown method ${command.method}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Убить всё при остановке демона: процессы не должны пережить клиента. */
|
|
109
|
+
export function stopAll () {
|
|
110
|
+
registry?.stopAll()
|
|
111
|
+
}
|