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

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/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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.60",
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
+ }