@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/store.mjs
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Хранилище клиента: три разных вещи — в трёх разных местах.
|
|
3
|
+
*
|
|
4
|
+
* Раньше всё лежало в одном `config.json`: и настройки человека, и токены, и
|
|
5
|
+
* курсор. Демон сохраняет курсор после каждой команды, поэтому любая правка из
|
|
6
|
+
* CLI (например, `dshnc workdir`) затиралась через секунду — и лечилось это
|
|
7
|
+
* слиянием при записи, то есть заплаткой.
|
|
8
|
+
*
|
|
9
|
+
* Теперь так:
|
|
10
|
+
* config.json — то, что задаёт человек: имя, алиасы, рабочая директория
|
|
11
|
+
* credentials.yaml — токены узлов (как `.dsh/.credentials.yaml`, режим 0600)
|
|
12
|
+
* state.sqlite — рантайм: курсор, epoch, номера API; пишет только демон
|
|
13
|
+
*
|
|
14
|
+
* Миграция из старого формата делается при открытии и только один раз.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-node-client/store
|
|
17
|
+
*/
|
|
18
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { randomBytes } from 'node:crypto'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml'
|
|
24
|
+
|
|
25
|
+
/** Каталог состояния клиента. */
|
|
26
|
+
export function home () {
|
|
27
|
+
return process.env.DSHNC_HOME ?? join(homedir(), '.dshnc')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureDir (dir) {
|
|
31
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
32
|
+
return dir
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Настройки человека. */
|
|
36
|
+
export const DEFAULT_SETTINGS = { id: null, name: null, aliases: [], workdir: null }
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Открыть хранилище клиента.
|
|
41
|
+
* @param {object} [options]
|
|
42
|
+
* @param {string} [options.dir] каталог состояния (по умолчанию `~/.dshnc`)
|
|
43
|
+
* @returns {object} хранилище
|
|
44
|
+
*/
|
|
45
|
+
export function openStore ({ dir = home() } = {}) {
|
|
46
|
+
ensureDir(dir)
|
|
47
|
+
// YAML канонический: в нём можно писать комментарии, а JSON остаётся
|
|
48
|
+
// читаемым — YAML 1.2 надмножество JSON, старый config.json откроется как есть
|
|
49
|
+
const yamlFile = join(dir, 'config.yaml')
|
|
50
|
+
const jsonFile = join(dir, 'config.json')
|
|
51
|
+
const settingsFile = existsSync(yamlFile) || !existsSync(jsonFile) ? yamlFile : jsonFile
|
|
52
|
+
const credentialsFile = join(dir, 'credentials.yaml')
|
|
53
|
+
const stateFile = join(dir, 'state.sqlite')
|
|
54
|
+
|
|
55
|
+
const db = new DatabaseSync(stateFile)
|
|
56
|
+
|
|
57
|
+
const settings = readSettings()
|
|
58
|
+
let credentials = readCredentials()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Прогнать миграции схемы.
|
|
62
|
+
*
|
|
63
|
+
* Правило проекта: схема `state.sqlite` меняется только цепочкой миграций,
|
|
64
|
+
* и цепочка исполняется при открытии хранилища. Учёт исполненных — в самой
|
|
65
|
+
* БД, в таблице `migrations`: повторно шаг не применяется никогда, даже если
|
|
66
|
+
* файл открыли старым клиентом и вернули новый.
|
|
67
|
+
*
|
|
68
|
+
* Рантайм (курсор, epoch, apiIds) восстановим: клиент заново объявится, и
|
|
69
|
+
* сервер сам скажет сбросить курсор при смене поколения. Поэтому шаг миграции
|
|
70
|
+
* вправе пересоздать таблицу, а не героически спасать данные.
|
|
71
|
+
*
|
|
72
|
+
* @param {DatabaseSync} database
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* Цепочка миграций схемы. Каждый шаг имеет постоянный `id`; порядок в массиве
|
|
76
|
+
* и есть порядок применения. Удалять и переименовывать шаги нельзя — только
|
|
77
|
+
* добавлять новые в конец.
|
|
78
|
+
*/
|
|
79
|
+
const migrations = [
|
|
80
|
+
{ id: '0001_init', up: () => {} },
|
|
81
|
+
{ id: '0002_split_config', up: () => importLegacySettings() },
|
|
82
|
+
{
|
|
83
|
+
// сессия может переехать в другой каталог (плагин `session`), и выбор
|
|
84
|
+
// должен пережить рестарт демона
|
|
85
|
+
id: '0003_session_workdir',
|
|
86
|
+
up: (database) => database.exec(`CREATE TABLE IF NOT EXISTS session_workdir (
|
|
87
|
+
session TEXT PRIMARY KEY,
|
|
88
|
+
workdir TEXT NOT NULL,
|
|
89
|
+
updated_at INTEGER NOT NULL
|
|
90
|
+
)`),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
// команды без песочницы оставляют след: кто, что, когда и с каким кодом
|
|
94
|
+
id: '0004_audit',
|
|
95
|
+
up: (database) => database.exec(`CREATE TABLE IF NOT EXISTS audit (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
session TEXT,
|
|
98
|
+
mode TEXT NOT NULL,
|
|
99
|
+
command TEXT NOT NULL,
|
|
100
|
+
code INTEGER,
|
|
101
|
+
at INTEGER NOT NULL
|
|
102
|
+
)`),
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
function runMigrations (database) {
|
|
107
|
+
database.exec(`CREATE TABLE IF NOT EXISTS server (
|
|
108
|
+
url TEXT PRIMARY KEY,
|
|
109
|
+
cursor INTEGER,
|
|
110
|
+
epoch INTEGER,
|
|
111
|
+
boot TEXT,
|
|
112
|
+
node INTEGER,
|
|
113
|
+
alias TEXT,
|
|
114
|
+
api_ids TEXT,
|
|
115
|
+
updated_at INTEGER
|
|
116
|
+
)`)
|
|
117
|
+
database.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)')
|
|
118
|
+
database.exec(`CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`)
|
|
119
|
+
const rows = database.prepare('SELECT id FROM migrations').all()
|
|
120
|
+
const done = new Set(rows.map((row) => row.id))
|
|
121
|
+
for (const migration of migrations) {
|
|
122
|
+
if (done.has(migration.id)) continue
|
|
123
|
+
migration.up(database)
|
|
124
|
+
database.prepare('INSERT INTO migrations (id, applied_at) VALUES (?, ?)').run(migration.id, Date.now())
|
|
125
|
+
database.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', migration.id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── настройки ──────────────────────────────────────────────────────────
|
|
130
|
+
function readSettings () {
|
|
131
|
+
let parsed = {}
|
|
132
|
+
for (const file of [yamlFile, jsonFile]) {
|
|
133
|
+
if (!existsSync(file)) continue
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseYaml(readFileSync(file, 'utf8')) ?? {}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// битый конфиг не должен молча превращаться в «настройки по умолчанию»:
|
|
138
|
+
// иначе клиент запустится с чужими правилами и никто не поймёт почему
|
|
139
|
+
throw new Error(`не смог разобрать ${file}: ${error.message}`)
|
|
140
|
+
}
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
return { ...DEFAULT_SETTINGS, ...parsed }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Записать настройки в YAML, сохранив комментарии человека.
|
|
148
|
+
*
|
|
149
|
+
* Пересборка файла из объекта убила бы все пояснения, поэтому правим документ
|
|
150
|
+
* по ключам. Комментарии внутри изменяемого значения не выживут — это
|
|
151
|
+
* неизбежно, и лучше, чем потерять весь файл.
|
|
152
|
+
*/
|
|
153
|
+
function editYaml (file, plain) {
|
|
154
|
+
let doc
|
|
155
|
+
try {
|
|
156
|
+
const raw = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
157
|
+
doc = parseDocument(raw)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(`не смог разобрать ${file}: ${error.message}`)
|
|
160
|
+
}
|
|
161
|
+
// не затирать файл, который человек написал с ошибкой: пусть починит, а не
|
|
162
|
+
// потеряет настройки вместе с комментариями
|
|
163
|
+
if (doc.errors?.length) throw new Error(`в ${file} ошибка YAML: ${doc.errors[0].message}`)
|
|
164
|
+
for (const [key, value] of Object.entries(plain)) doc.set(key, value)
|
|
165
|
+
return String(doc)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function saveSettings (patch = {}) {
|
|
169
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
170
|
+
if (value === undefined) continue
|
|
171
|
+
settings[key] = value
|
|
172
|
+
}
|
|
173
|
+
// в конфиге больше не место токенам и курсорам
|
|
174
|
+
const plain = { ...settings }
|
|
175
|
+
delete plain.servers
|
|
176
|
+
const target = existsSync(yamlFile) || !existsSync(jsonFile) ? yamlFile : jsonFile
|
|
177
|
+
const text = target === yamlFile ? editYaml(target, plain) : JSON.stringify(plain, null, 2) + '\n'
|
|
178
|
+
writeFileSync(target, text, { mode: 0o600 })
|
|
179
|
+
return plain
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── токены ─────────────────────────────────────────────────────────────
|
|
183
|
+
function readCredentials () {
|
|
184
|
+
try {
|
|
185
|
+
if (!existsSync(credentialsFile)) return {}
|
|
186
|
+
const parsed = parseYaml(readFileSync(credentialsFile, 'utf8')) ?? {}
|
|
187
|
+
const out = {}
|
|
188
|
+
// Текущий формат — список объектов: редактор не должен ломать глаза о
|
|
189
|
+
// ключи-урлы. Старый формат (карта url → token) читаем тоже, иначе
|
|
190
|
+
// обновление клиента стёрло бы все токены.
|
|
191
|
+
if (Array.isArray(parsed.servers)) {
|
|
192
|
+
for (const entry of parsed.servers) {
|
|
193
|
+
if (typeof entry?.url === 'string' && typeof entry?.token === 'string' && entry.token) {
|
|
194
|
+
out[entry.url] = entry.token
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
for (const [url, token] of Object.entries(parsed)) {
|
|
200
|
+
if (typeof token === 'string' && token) out[url] = token
|
|
201
|
+
}
|
|
202
|
+
return out
|
|
203
|
+
} catch {
|
|
204
|
+
return {}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writeCredentials () {
|
|
209
|
+
const existing = (() => {
|
|
210
|
+
try {
|
|
211
|
+
return parseYaml(readFileSync(credentialsFile, 'utf8')) ?? {}
|
|
212
|
+
} catch {
|
|
213
|
+
return {}
|
|
214
|
+
}
|
|
215
|
+
})()
|
|
216
|
+
const body = stringifyYaml({
|
|
217
|
+
...(existing.client_nonce ? { client_nonce: existing.client_nonce } : {}),
|
|
218
|
+
servers: Object.entries(credentials).map(([url, token]) => ({ url, token })),
|
|
219
|
+
}, { lineWidth: 0 })
|
|
220
|
+
writeFileSync(credentialsFile, body, { mode: 0o600 })
|
|
221
|
+
try {
|
|
222
|
+
chmodSync(credentialsFile, 0o600)
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Секрет клиента для заявок: uuid не секрет, поэтому токен по заявке должен
|
|
228
|
+
* получить только тот, кто знает этот nonce. Живёт рядом с токенами.
|
|
229
|
+
*/
|
|
230
|
+
function clientNonce () {
|
|
231
|
+
try {
|
|
232
|
+
if (!existsSync(credentialsFile)) return ensureClientNonce()
|
|
233
|
+
const parsed = parseYaml(readFileSync(credentialsFile, 'utf8')) ?? {}
|
|
234
|
+
if (typeof parsed.client_nonce === 'string' && parsed.client_nonce) return parsed.client_nonce
|
|
235
|
+
} catch {}
|
|
236
|
+
return ensureClientNonce()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureClientNonce () {
|
|
240
|
+
const current = (() => {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = parseYaml(readFileSync(credentialsFile, 'utf8')) ?? {}
|
|
243
|
+
return typeof parsed.client_nonce === 'string' ? parsed.client_nonce : null
|
|
244
|
+
} catch {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
})()
|
|
248
|
+
if (current) return current
|
|
249
|
+
const nonce = randomBytes(24).toString('base64url')
|
|
250
|
+
writeCredentials()
|
|
251
|
+
const parsed = (() => {
|
|
252
|
+
try {
|
|
253
|
+
return parseYaml(readFileSync(credentialsFile, 'utf8')) ?? {}
|
|
254
|
+
} catch {
|
|
255
|
+
return {}
|
|
256
|
+
}
|
|
257
|
+
})()
|
|
258
|
+
parsed.client_nonce = nonce
|
|
259
|
+
writeFileSync(credentialsFile, stringifyYaml(parsed, { lineWidth: 0 }), { mode: 0o600 })
|
|
260
|
+
return nonce
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function token (url) {
|
|
264
|
+
// читаем файл заново: в одном процессе живёт несколько хранилищ (демон,
|
|
265
|
+
// обмен кодом, CLI), и у каждого свой снимок — иначе токен, записанный
|
|
266
|
+
// одним, для другого выглядит пустым
|
|
267
|
+
const fresh = readCredentials()
|
|
268
|
+
for (const key of Object.keys(fresh)) credentials[key] = fresh[key]
|
|
269
|
+
return fresh[url] ?? null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function setToken (url, value) {
|
|
273
|
+
const fresh = readCredentials()
|
|
274
|
+
if (value) fresh[url] = value
|
|
275
|
+
else delete fresh[url]
|
|
276
|
+
credentials = fresh
|
|
277
|
+
writeCredentials()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── рантайм ────────────────────────────────────────────────────────────
|
|
281
|
+
function readState (url) {
|
|
282
|
+
const row = db.prepare('SELECT * FROM server WHERE url = ?').get(url)
|
|
283
|
+
if (!row) return { url, cursor: null, epoch: null, boot: null, node: null, alias: null, apiIds: {} }
|
|
284
|
+
let apiIds = {}
|
|
285
|
+
try {
|
|
286
|
+
apiIds = row.api_ids ? JSON.parse(row.api_ids) : {}
|
|
287
|
+
} catch {
|
|
288
|
+
apiIds = {}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
url,
|
|
292
|
+
cursor: row.cursor,
|
|
293
|
+
epoch: row.epoch,
|
|
294
|
+
boot: row.boot,
|
|
295
|
+
node: row.node,
|
|
296
|
+
alias: row.alias,
|
|
297
|
+
apiIds,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function writeState (url, patch = {}) {
|
|
302
|
+
const merged = { ...readState(url), ...patch }
|
|
303
|
+
db.prepare(`INSERT INTO server (url, cursor, epoch, boot, node, alias, api_ids, updated_at)
|
|
304
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
305
|
+
ON CONFLICT(url) DO UPDATE SET
|
|
306
|
+
cursor = excluded.cursor, epoch = excluded.epoch, boot = excluded.boot,
|
|
307
|
+
node = excluded.node, alias = excluded.alias, api_ids = excluded.api_ids,
|
|
308
|
+
updated_at = excluded.updated_at`)
|
|
309
|
+
.run(url, merged.cursor ?? null, merged.epoch ?? null, merged.boot ?? null,
|
|
310
|
+
merged.node ?? null, merged.alias ?? null, JSON.stringify(merged.apiIds ?? {}), Date.now())
|
|
311
|
+
return merged
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Каталог сессии, выбранный через плагин `session`. */
|
|
315
|
+
function readSessionDir (session) {
|
|
316
|
+
const row = db.prepare('SELECT workdir FROM session_workdir WHERE session = ?').get(String(session))
|
|
317
|
+
return row?.workdir ?? null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function writeSessionDir (session, workdir) {
|
|
321
|
+
db.prepare(`INSERT INTO session_workdir (session, workdir, updated_at) VALUES (?, ?, ?)
|
|
322
|
+
ON CONFLICT(session) DO UPDATE SET workdir = excluded.workdir, updated_at = excluded.updated_at`)
|
|
323
|
+
.run(String(session), String(workdir), Date.now())
|
|
324
|
+
return workdir
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Записать команду, исполненную без песочницы.
|
|
329
|
+
*
|
|
330
|
+
* Это не защита, а след: в режиме `full` клиент исполняет что угодно, и
|
|
331
|
+
* единственное, что можно сделать — оставить запись, чтобы потом было видно,
|
|
332
|
+
* что именно выполнялось.
|
|
333
|
+
*/
|
|
334
|
+
function writeAudit ({ session, mode, command, code, at = Date.now() }) {
|
|
335
|
+
db.prepare('INSERT INTO audit (session, mode, command, code, at) VALUES (?, ?, ?, ?, ?)')
|
|
336
|
+
.run(session ?? null, String(mode), String(command).slice(0, 4000), code ?? null, at)
|
|
337
|
+
// держим кольцо: узел живёт месяцами, аудит не должен съесть диск
|
|
338
|
+
trimAudit({})
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Кольцо аудита: база на узле не должна расти годами. */
|
|
343
|
+
function trimAudit ({ maxRows = 2000, maxAgeMs = 7 * 24 * 60 * 60 * 1000 } = {}) {
|
|
344
|
+
if (maxAgeMs > 0) db.prepare('DELETE FROM audit WHERE at < ?').run(Date.now() - maxAgeMs)
|
|
345
|
+
if (maxRows > 0) {
|
|
346
|
+
db.prepare(`DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)`).run(maxRows)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Последние записи аудита, свежие первыми. */
|
|
351
|
+
function readAudit (limit = 20) {
|
|
352
|
+
return db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 20)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Все известные серверы: из токенов и из рантайма. */
|
|
356
|
+
function servers () {
|
|
357
|
+
const fresh = readCredentials()
|
|
358
|
+
const set = new Set([...Object.keys(fresh), ...db.prepare('SELECT url FROM server').all().map((row) => row.url)])
|
|
359
|
+
return [...set]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Удалить сервер полностью: токен и рантайм. */
|
|
363
|
+
function forget (url) {
|
|
364
|
+
delete credentials[url]
|
|
365
|
+
writeCredentials()
|
|
366
|
+
db.prepare('DELETE FROM server WHERE url = ?').run(url)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Перенос старого config.json (servers → токены + рантайм).
|
|
371
|
+
*
|
|
372
|
+
* Это шаг миграции `0002_split_config`: учёт исполненного лежит в таблице
|
|
373
|
+
* `migrations`, поэтому повторно ничего не произойдёт.
|
|
374
|
+
*/
|
|
375
|
+
function importLegacySettings () {
|
|
376
|
+
const legacy = settings.servers ?? {}
|
|
377
|
+
for (const [url, entry] of Object.entries(legacy)) {
|
|
378
|
+
if (entry?.token) credentials[url] = entry.token
|
|
379
|
+
writeState(url, {
|
|
380
|
+
cursor: entry?.cursor ?? null,
|
|
381
|
+
epoch: entry?.epoch ?? null,
|
|
382
|
+
boot: entry?.boot ?? null,
|
|
383
|
+
node: entry?.node ?? null,
|
|
384
|
+
alias: entry?.alias ?? null,
|
|
385
|
+
apiIds: entry?.apiIds ?? {},
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
if (Object.keys(legacy).length) writeCredentials()
|
|
389
|
+
// Файл переписываем только если что-то реально переносили: иначе миграция
|
|
390
|
+
// съедает комментарии в конфиге, хотя менять в нём ничего не собиралась
|
|
391
|
+
if (!Object.keys(legacy).length) return
|
|
392
|
+
delete settings.servers
|
|
393
|
+
// пишем в том же формате, что и всё остальное: если конфиг YAML — YAML
|
|
394
|
+
const target = settingsFile
|
|
395
|
+
const text = target.endsWith('.yaml')
|
|
396
|
+
? stringifyYaml(settings, { lineWidth: 0 })
|
|
397
|
+
: JSON.stringify(settings, null, 2) + '\n'
|
|
398
|
+
writeFileSync(target, text, { mode: 0o600 })
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// цепочка миграций — когда все шаги и замыкания уже определены
|
|
402
|
+
runMigrations(db)
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
dir,
|
|
406
|
+
files: { settings: settingsFile, credentials: credentialsFile, state: stateFile },
|
|
407
|
+
get settings () {
|
|
408
|
+
return settings
|
|
409
|
+
},
|
|
410
|
+
saveSettings,
|
|
411
|
+
token,
|
|
412
|
+
setToken,
|
|
413
|
+
clientNonce,
|
|
414
|
+
readState,
|
|
415
|
+
writeState,
|
|
416
|
+
readSessionDir,
|
|
417
|
+
writeSessionDir,
|
|
418
|
+
writeAudit,
|
|
419
|
+
readAudit,
|
|
420
|
+
trimAudit,
|
|
421
|
+
servers,
|
|
422
|
+
forget,
|
|
423
|
+
close () {
|
|
424
|
+
try {
|
|
425
|
+
db.close()
|
|
426
|
+
} catch {}
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Атомарно переписать файл (используется снаружи для редких случаев). */
|
|
432
|
+
export function atomicWrite (file, text, mode = 0o600) {
|
|
433
|
+
const temp = `${file}.tmp`
|
|
434
|
+
writeFileSync(temp, text, { mode })
|
|
435
|
+
renameSync(temp, file)
|
|
436
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dr-co/dsh-node-client",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.59",
|
|
4
|
+
"description": "dshnc — клиент dsh-nodes: объявляет bash на машине узла и исполняет команды",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dshnc": "./bin/dshnc.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./lib/runner.mjs",
|
|
11
|
+
"./api": "./lib/api.mjs",
|
|
12
|
+
"./bash": "./lib/bash.mjs",
|
|
13
|
+
"./config": "./lib/config.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"lib/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test --test-concurrency=1 test/*.test.mjs"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"dsh",
|
|
25
|
+
"dshnc",
|
|
26
|
+
"node",
|
|
27
|
+
"remote",
|
|
28
|
+
"agent"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/unera/dsh-node-client.git"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"ws": "^8.18.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"yaml": "^2.9.1"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|