@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 CHANGED
@@ -1,3 +1,277 @@
1
- # Temporary Holding Version
1
+ # dsh-node-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `dshnc` — the node side of [dsh-nodes](https://github.com/unera/dsh-nodes):
4
+ it connects a machine to a DSH server, fetches commands, runs them inside a
5
+ sandbox and reports back.
6
+
7
+ Zero runtime dependencies. One foreground process, systemd-friendly, no daemon
8
+ libraries.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install -g @dr-co/dsh-node-client
14
+ systemctl --user restart dshnc
15
+ ```
16
+
17
+ ## Connect
18
+
19
+ ```sh
20
+ dshnc connect 'https://host/dsh-nodes/pair?v=<code>' # pairing link
21
+ dshnc daemon # serve every configured server
22
+ ```
23
+
24
+ ## Built-in APIs
25
+
26
+ Everything the client can do lives in a plugin. Two ship in the box.
27
+
28
+ **`bash`** — run a shell command.
29
+
30
+ ```
31
+ exec(command, cwd?, timeout?) → stdout, stderr, {"code":…,"signal":…,"cwd":…,"sandbox":{…}}
32
+ ```
33
+
34
+ Output above 48 KiB is truncated with a visible marker. A command that outlives
35
+ its timeout is killed. The `sandbox` block in the last result is the client's
36
+ honest report: the mode it actually ran in, whether the kernel denied something
37
+ and under which signature (`read-only file system` and the like).
38
+
39
+ **`job`** — long-running processes.
40
+
41
+ ```
42
+ start(command, cwd?, timeout?) → {id, status}
43
+ output(id, offset?) → {status, code, stdout, stderr, offset, sandbox}
44
+ kill(id, signal?) → SIGTERM to the whole process group
45
+ list() → what runs and how it ended
46
+ ```
47
+
48
+ A job runs through the same sandbox as `exec`: the mode that would confine a
49
+ command confines its background process too. Output is kept in a ring (256 KiB)
50
+ and read by absolute offsets; `sandbox` in the answer says whether isolation was
51
+ applied. Jobs die with the daemon: sandboxed ones through `--die-with-parent`,
52
+ the rest through `stopAll()` on SIGTERM.
53
+
54
+ **`files`** — reading and writing without a shell.
55
+
56
+ ```
57
+ read(path, offset?, limit?) → {text, version, size, truncated}
58
+ write(path, content, ifVersion?, createDirs?) → {version, bytes}
59
+ edit(path, old, new, replaceAll?, ifVersion?) → {version, replaced}
60
+ list(path) → {entries: [{name, type, size, mtime}]}
61
+ stat(path) → {exists, type, size, version}
62
+ ```
63
+
64
+ Paths are checked against `roots` **before** touching the disk: `/etc/hostname`
65
+ answers `path_denied`, not "read it anyway". Writes are atomic (temp file plus
66
+ `rename`), and `ifVersion` turns a silent overwrite into a `stale` error.
67
+ `edit` refuses ambiguity: a string found twice without `replaceAll` gives
68
+ `ambiguous_edit`.
69
+
70
+ **`session`** — session state on the node, version 4.
71
+
72
+ ```
73
+ chdir(path) → move the session workdir inside the allowed roots
74
+ mkdir(path, mode?) → create a directory for the session
75
+ cwd() → where the session currently is
76
+ approve(id, text, mode) → ask the human on this machine; returns ok/cancel
77
+ ping() → {pong, sentAt, rttMs, upSeconds, platform, version}
78
+ ```
79
+
80
+ `ping` is the cheap liveness check: the node answers with its own clock and
81
+ uptime, and the server fills `rttMs` and `clockSkewMs` — the node cannot time a
82
+ round trip because it has never seen the server clock. It answers even on a node
83
+ without a session store, and the server caps it at 15 seconds: a hung node must
84
+ not hold the model for its full timeout.
85
+
86
+ The tool is started with these variables, so a dialog can show what is being
87
+ asked and remember a standing answer:
88
+
89
+ | variable | meaning |
90
+ |---|---|
91
+ | `DSHNC_APPROVAL_ID` | id of this question |
92
+ | `DSHNC_APPROVAL_MODE` | mode that is being requested |
93
+ | `DSHNC_SESSION` | model session; "answer yes until the end of this session" |
94
+ | `DSHNC_QUESTION` | root call of the turn; "until the end of this question" |
95
+ | `DSHNC_CALL` | the exact call, for logs |
96
+
97
+ `approve` is deliberately not a shell command: it runs the tool configured in
98
+ `ask.local.tool` (a `zenity` dialog, a script, anything), never goes through the
99
+ sandbox, and cannot be denied by the kernel. Without a configured tool it fails
100
+ with `no_local_approver` instead of pretending a question was asked.
101
+
102
+ Bump the API version whenever its contract changes. The version is the
103
+ **contract** version: nodes announcing different versions of the same name stay
104
+ separate, and fusing them would make the server use whichever schema it saw
105
+ first.
106
+
107
+ ## Write your own plugin
108
+
109
+ Drop a directory into `~/.dshnc/plugins/<name>/index.mjs`; it is loaded at
110
+ startup and its API is announced to the server.
111
+
112
+ ```js
113
+ export const api = {
114
+ name: 'lamp', // what the server will see
115
+ version: '1', // bump with the contract
116
+ description: 'Desk lamp',
117
+ methods: [{
118
+ method: 'set',
119
+ description: 'Turn on or off',
120
+ args: {
121
+ type: 'object',
122
+ properties: { state: { type: 'string', enum: ['on', 'off'] } },
123
+ required: ['state'],
124
+ additionalProperties: false,
125
+ },
126
+ }],
127
+ }
128
+
129
+ export async function handle (command, ctx) {
130
+ if (command.method !== 'set') {
131
+ return { ok: false, code: 'unknown_method', message: `no method ${command.method}` }
132
+ }
133
+ return { ok: true, results: [{ type: 'text', text: `lamp ${command.args.state}` }] }
134
+ }
135
+ ```
136
+
137
+ `args` is mandatory (at least `{ type: 'object' }`): the server refuses an
138
+ announcement without a schema. `handle` returns `{ok: true, results: [...]}` or
139
+ `{ok: false, code, message}` with a code from the server's vocabulary
140
+ (`bad_args`, `timeout`, `internal`, …). `ctx` carries `shell`, `signal`
141
+ (cancellation of that call), `sandbox`, `sessions` and `approver`.
142
+
143
+ A plugin that throws is dropped with a warning; the rest keep working. A plugin
144
+ claiming a name that is already taken is skipped.
145
+
146
+ ## Sandbox and policy
147
+
148
+ The owner of the machine decides what the server may do. Policy lives on the
149
+ node, not on the server:
150
+
151
+ ```json
152
+ {
153
+ "max": "full", // absolute ceiling, never exceeded
154
+ "base": "rw", // mode a command starts in; approved commands skip it
155
+ "approver": "local", // local | server | none
156
+ "roots": ["/home/unera/work/ai"],
157
+ "workdir": "/home/unera/work/ai/.dsh",
158
+ "ask": {
159
+ "local": { "enabled": true, "tool": { "type": "external", "command": "zenity --text-info --width=600" } }
160
+ }
161
+ }
162
+ ```
163
+
164
+ `dshnc policy` reads and edits it:
165
+
166
+ ```sh
167
+ dshnc policy https://host/dsh-nodes max=full base=rw approver=local \
168
+ tool="zenity --text-info --width=600"
169
+ ```
170
+
171
+ The template in `ask.local.tool` either contains `%s` (the question is
172
+ substituted into an argument) or not (the question goes to stdin, as
173
+ `zenity --text-info` expects — then its length is unbounded). Structured data
174
+ travelling with the question arrives in `DSHNC_APPROVAL_ID` and
175
+ `DSHNC_APPROVAL_MODE`. The command is started without a shell, so nothing from
176
+ the question can become shell syntax.
177
+
178
+ Sandboxing uses `bwrap` when it is available: `ro` is a read-only bind of `/`,
179
+ `rw` adds writable binds for `roots`, `full` runs unconfined. `no_new_privs` is
180
+ set, so `sudo` and setuid do not work inside. Network is unshared in `ro`/`rw`.
181
+
182
+ ## Audit
183
+
184
+ Commands executed without a sandbox leave a trace:
185
+
186
+ ```sh
187
+ dshnc audit 20
188
+ ```
189
+
190
+ Rows are kept in a ring (2000 rows, 7 days) — a node that lives for months must
191
+ not fill the disk with a log nobody reads.
192
+
193
+ ## Control channel
194
+
195
+ Polling pulls batches over HTTP; a WebSocket (`/v1/ws`, token in the
196
+ subprotocol, never in the URL) tells the client that work exists, so it does not
197
+ wait for the next poll. Without the socket the client still works, just with
198
+ poll latency.
199
+
200
+ Batches run in parallel with a cap (`maxConcurrent`, default 5): one model turn
201
+ can produce a dozen calls, and a node must not be flooded. `session.chdir` is
202
+ applied first and in order — it changes the session working directory, so the
203
+ rest of the batch must start in the new one. `session.approve` runs outside the
204
+ pool: a dialog may hang for a day and must not hold a slot.
205
+
206
+ ## MCP (planned)
207
+
208
+ The client is a natural place to bridge MCP servers: a local MCP server becomes
209
+ one more API on the node, and the model reaches it through `nodes_call` without
210
+ learning new tools. Two transports are wanted:
211
+
212
+ | transport | how it is configured | note |
213
+ |---|---|---|
214
+ | stdio | `command` + `args`, client spawns the process | works for `npx @modelcontextprotocol/server-*`, no network |
215
+ | `host:port` | URL of an HTTP/SSE endpoint | the server may live elsewhere, the node only proxies |
216
+
217
+ An MCP server named `files` would be announced as API `mcp-files` with one
218
+ method per MCP tool, the tool's JSON Schema reused as `args`. Nothing in the
219
+ protocol changes: the server already passes arbitrary schemas through.
220
+
221
+ Not implemented yet. When it is, the section will say so plainly instead of
222
+ describing intentions.
223
+
224
+ ## First-run setup
225
+
226
+ `dshnc onboard` is a menu-driven wizard: it asks one section at a time and
227
+ explains what each answer means, because the answers are policy — who may do what
228
+ on this machine — not cosmetics.
229
+
230
+ - current values are shown in brackets and an empty answer keeps them, so the
231
+ wizard never silently changes a node that already works;
232
+ - `доступ` — name, session directory, allowed directories, ceiling (`ro`/`rw`/
233
+ `full`), starting mode, who answers questions, and the command used to ask
234
+ (any tool that exits 0 on "yes", not just `zenity`);
235
+ - `связь` — periodic polling, socket ping interval and how far it may stretch;
236
+ - `автозапуск` — delegates to the systemd unit wizard;
237
+ - before writing, it prints the resulting config and asks for confirmation.
238
+
239
+ `dshnc config key=value` changes top-level settings, `dshnc policy <url> key=value`
240
+ changes per-server rules; both edit the file in place and keep its comments.
241
+
242
+ ## Install
243
+
244
+ ```
245
+ npm install -g @dr-co/dsh-node-client
246
+ ```
247
+
248
+ The same thing straight from git, without npm:
249
+
250
+ ```
251
+ npm install -g unera/dsh-node-client
252
+ ```
253
+
254
+ Both give you the `dshnc` binary. The git form follows `main`, the npm form gives
255
+ you a tagged release — pick npm for a node that has to keep working.
256
+
257
+ ## Commands
258
+
259
+ | command | what it does |
260
+ |---|---|
261
+ | `dshnc connect <url>` | pairing link — at once, otherwise a request pending approval |
262
+ | `dshnc daemon [url...]` | announce APIs, fetch commands, answer; no arguments — every configured server |
263
+ | `dshnc status` | client id and saved servers |
264
+ | `dshnc policy [url] [key=value…]` | show or change policy for one server |
265
+ | `dshnc audit [n]` | commands executed without the sandbox |
266
+ | `dshnc workdir [path]` | show or set the session working directory |
267
+
268
+ One client serves several servers: token, cursor and epoch are kept **per URL**.
269
+
270
+ ## Development
271
+
272
+ ```sh
273
+ npm test # node --test, sequential: integration tests start real servers
274
+ ```
275
+
276
+ The Russian guide is in [`docs/guide.ru.md`](docs/guide.ru.md); storage rules,
277
+ policy and sandbox notes are in [`STORAGE.md`](STORAGE.md).
package/bin/dshnc.mjs ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dshnc — клиент dsh-nodes.
4
+ *
5
+ * dshnc help
6
+ * dshnc connect <url> заявка на сервере и ожидание одобрения
7
+ * dshnc daemon [url...] держать соединения и исполнять команды
8
+ * dshnc status что сохранено в ~/.dshnc
9
+ * dshnc version версия клиента и версия протокола
10
+ *
11
+ * Один клиент может работать с несколькими серверами: состояние соединений
12
+ * (токен, курсор, epoch) хранится per URL.
13
+ */
14
+ import { join } from 'node:path'
15
+ import { loadConfig, normalizeUrl } from '../lib/config.mjs'
16
+ import { enrollServer, pairServer, runServer } from '../lib/runner.mjs'
17
+ import * as jobPlugin from '../lib/plugins/job.mjs'
18
+ import { loadPlugins } from '../lib/plugins/index.mjs'
19
+ import { detectLang, setLang, t } from '../lib/i18n.mjs'
20
+ import { applyAutostartAnswers, resolveExecStart, runAutostart } from '../lib/autostart.mjs'
21
+ import { formatQuestion } from '../lib/ask.mjs'
22
+ import { home, openStore } from '../lib/store.mjs'
23
+ import { PROTO, VERSION } from '../lib/api.mjs'
24
+ import { createInterface } from 'node:readline/promises'
25
+ import { mkdirSync } from 'node:fs'
26
+ import { resolve } from 'node:path'
27
+
28
+ const HELP = () => [
29
+ t('help.title', { home: home() }),
30
+ '',
31
+ ' ' + t('help.connect'),
32
+ ' ' + t('help.connect2'),
33
+ ' ' + t('help.daemon'),
34
+ ' ' + t('help.daemon2'),
35
+ ' ' + t('help.daemon3'),
36
+ ' ' + t('help.foreground'),
37
+ ' ' + t('help.onboard'),
38
+ ' ' + t('help.config'),
39
+ ' ' + t('help.policy'),
40
+ ' ' + t('help.audit'),
41
+ ' ' + t('help.workdir'),
42
+ ' ' + t('help.autostart'),
43
+ ' ' + t('help.status'),
44
+ ' ' + t('help.version'),
45
+ ' ' + t('help.help'),
46
+ '',
47
+ ' ' + t('help.env'),
48
+ '',
49
+ ].join('\n')
50
+
51
+ const out = (line) => process.stdout.write(`${line}\n`)
52
+
53
+ async function main (argv) {
54
+ setLang(detectLang())
55
+ const [command, ...rest] = argv
56
+ const config = loadConfig()
57
+
58
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
59
+ out(HELP())
60
+ return 0
61
+ }
62
+
63
+ // Мастер настройки: меню, а не поток вопросов. Ведёт себя как «первичная
64
+ // настройка», но годится и для правок: текущие значения он предлагает по
65
+ // умолчанию, поэтому пустой ответ ничего не меняет.
66
+ if (command === 'onboard') {
67
+ const store = openStore()
68
+ const { runOnboard, pickPolicy } = await import('../lib/onboard.mjs')
69
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
70
+ const ask = async (question) => {
71
+ const answer = await rl.question(question)
72
+ return answer
73
+ }
74
+ try {
75
+ await runOnboard({
76
+ ask,
77
+ print: (line) => out(line),
78
+ load: () => openStore().settings ?? {},
79
+ save: (patch) => openStore().saveSettings(patch),
80
+ autostart: async (cfg) => {
81
+ const answers = await runAutostart({
82
+ ask,
83
+ print: (line) => out(line),
84
+ config: cfg ?? {},
85
+ execStart: resolveExecStart(process.argv[1]),
86
+ hasSudo: process.env.DSHNC_SUDO === '1',
87
+ })
88
+ applyAutostartAnswers(cfg ?? {}, answers)
89
+ },
90
+ status: (cfg) => {
91
+ const { url, policy } = pickPolicy(cfg ?? {})
92
+ return [
93
+ `узел: ${cfg?.name ?? '—'}`,
94
+ `сервер: ${url ?? 'не подключён'}`,
95
+ `каталоги: ${(policy.roots ?? cfg?.roots ?? []).join(', ') || '—'}`,
96
+ `режимы: base ${policy.base ?? '—'}, потолок ${policy.max ?? '—'}, вопросы ${policy.approver ?? '—'}`,
97
+ `связь: опрос ${cfg?.pollIdleMs ?? 0} мс, пинг ${cfg?.pingInterval ?? 0} с`,
98
+ ]
99
+ },
100
+ })
101
+ } finally {
102
+ rl.close()
103
+ }
104
+ return 0
105
+ }
106
+
107
+ if (command === 'autostart') {
108
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
109
+ const ask = async (question, fallback) => {
110
+ const answer = await rl.question(formatQuestion(question, fallback))
111
+ return answer.trim() || fallback || ''
112
+ }
113
+ try {
114
+ const result = await runAutostart({
115
+ ask,
116
+ print: out,
117
+ config,
118
+ execStart: resolveExecStart(process.argv[1]),
119
+ hasSudo: process.env.DSHNC_SUDO === '1',
120
+ })
121
+ applyAutostartAnswers(config, result)
122
+ openStore().saveSettings({ name: config.name, aliases: config.aliases })
123
+ out(t('auto.nodeSummary', { name: config.name })
124
+ + (config.aliases?.length ? t('auto.aliasesSuffix', { aliases: config.aliases.join(', ') }) : ''))
125
+ } finally {
126
+ rl.close()
127
+ }
128
+ return 0
129
+ }
130
+
131
+ if (command === 'version' || command === '--version' || command === '-v') {
132
+ out(t('version.line', { version: VERSION, proto: PROTO }))
133
+ return 0
134
+ }
135
+
136
+ if (command === 'workdir') {
137
+ if (rest[0]) {
138
+ const dir = resolve(rest[0])
139
+ mkdirSync(dir, { recursive: true })
140
+ try {
141
+ openStore().saveSettings({ workdir: dir })
142
+ } catch (error) {
143
+ process.stderr.write(t('err.writeState', { message: error.message }) + '\n')
144
+ return 1
145
+ }
146
+ config.workdir = dir
147
+ out(t('workdir.set', { dir: config.workdir }))
148
+ } else {
149
+ out(config.workdir ?? t('workdir.none'))
150
+ }
151
+ return 0
152
+ }
153
+
154
+ if (command === 'policy') {
155
+ const store = openStore()
156
+ const [url, ...pairs] = rest
157
+ if (!url) {
158
+ out(t('policy.current', { access: JSON.stringify(config.access ?? {}), roots: (config.roots ?? []).join(', ') || '—' }))
159
+ for (const [key, value] of Object.entries(config.policy ?? {})) {
160
+ out(`${key}: ${JSON.stringify(value)}`)
161
+ }
162
+ return 0
163
+ }
164
+ const target = normalizeUrl(url)
165
+ const patch = {}
166
+ for (const pair of pairs) {
167
+ // делим по первому «=»: значение само может содержать «=» (zenity --width=600)
168
+ const at = pair.indexOf('=')
169
+ if (at < 0) continue
170
+ const key = pair.slice(0, at)
171
+ const value = pair.slice(at + 1)
172
+ if (!value) continue
173
+ if (key === 'max') patch.max = value
174
+ if (key === 'ask') patch.ask = value === 'true' || value === '1'
175
+ if (key === 'roots') patch.roots = value.split(':').filter(Boolean)
176
+ if (key === 'base') patch.base = value
177
+ if (key === 'approver') patch.approver = value
178
+ if (key === 'tool') {
179
+ // внешний инструмент локального апрува: включаем канал и запоминаем команду
180
+ patch.ask = { local: { enabled: true, tool: { type: 'external', command: value } } }
181
+ }
182
+ if (key === 'workdir') patch.workdir = resolve(value)
183
+ }
184
+ if (!Object.keys(patch).length) {
185
+ out(JSON.stringify(config.policy?.[target] ?? {}, null, 2))
186
+ return 0
187
+ }
188
+ config.policy ??= {}
189
+ config.policy[target] = { ...(config.policy[target] ?? {}), ...patch }
190
+ try {
191
+ store.saveSettings({ policy: config.policy })
192
+ } catch (error) {
193
+ // частая причина — попытка поменять политику изнутри песочницы: писать
194
+ // в ~/.dshnc там нельзя, и молчать об этом нельзя тоже
195
+ process.stderr.write(t('err.writeState', { message: error.message }) + '\n')
196
+ return 1
197
+ }
198
+ out(t('policy.saved', { url: target, patch: JSON.stringify(patch) }))
199
+ return 0
200
+ }
201
+
202
+ // Настройки верхнего уровня: то, что относится к самому клиенту, а не к
203
+ // конкретному серверу. Нужны, чтобы менять поведение без правки файла руками
204
+ // (и без риска сломать YAML-комментарии).
205
+ if (command === 'config') {
206
+ const store = openStore()
207
+ const pairs = rest
208
+ if (!pairs.length) {
209
+ const plain = { ...config }
210
+ delete plain.servers
211
+ delete plain.policy
212
+ out(JSON.stringify(plain, null, 2))
213
+ return 0
214
+ }
215
+ const patch = {}
216
+ for (const pair of pairs) {
217
+ const at = pair.indexOf('=')
218
+ if (at < 0) continue
219
+ const key = pair.slice(0, at)
220
+ const raw = pair.slice(at + 1)
221
+ if (!key || raw === '') continue
222
+ const value = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw === 'true' ? true : raw === 'false' ? false : raw
223
+ patch[key] = value
224
+ }
225
+ if (!Object.keys(patch).length) {
226
+ out(t('config.usage'))
227
+ return 1
228
+ }
229
+ try {
230
+ store.saveSettings(patch)
231
+ } catch (error) {
232
+ process.stderr.write(t('err.writeState', { message: error.message }) + '\n')
233
+ return 1
234
+ }
235
+ out(t('config.saved', { patch: JSON.stringify(patch) }))
236
+ return 0
237
+ }
238
+
239
+ if (command === 'audit') {
240
+ const limit = Number(rest[0]) > 0 ? Number(rest[0]) : 20
241
+ const rows = openStore().readAudit(limit)
242
+ if (!rows.length) {
243
+ out(t('audit.empty'))
244
+ return 0
245
+ }
246
+ for (const row of rows) {
247
+ // локальное время: аудит читает человек, а не машина
248
+ const when = new Date(row.at).toLocaleString('sv-SE').replace('T', ' ')
249
+ const session = (row.session ?? '—').replace(/^session-/, '').slice(0, 8)
250
+ out(`${when} ${row.mode} код ${row.code ?? '—'} ${session} ${row.command.replace(/\s+/g, ' ').slice(0, 120)}`)
251
+ }
252
+ return 0
253
+ }
254
+
255
+ if (command === 'status') {
256
+ out(t('status.id', { id: config.id }))
257
+ out(t('status.name', { name: config.name }))
258
+ const store = openStore()
259
+ const servers = store.servers()
260
+ if (!servers.length) {
261
+ out(t('status.none'))
262
+ return 0
263
+ }
264
+ for (const url of servers) {
265
+ const state = { ...store.readState(url), token: store.token(url) }
266
+ out(t('status.server', {
267
+ url,
268
+ token: state.token ? t('status.token.yes') : t('status.token.no'),
269
+ cursor: state.cursor ?? '—',
270
+ epoch: state.epoch ?? '—',
271
+ alias: state.alias ? t('status.alias', { alias: state.alias }) : '',
272
+ }))
273
+ }
274
+ return 0
275
+ }
276
+
277
+ if (command === 'connect') {
278
+ const url = rest[0]
279
+ if (!url) {
280
+ process.stderr.write(t('err.needUrl') + '\n')
281
+ return 2
282
+ }
283
+ const withCode = url.includes('?')
284
+ const state = withCode
285
+ ? await pairServer({ url, config, print: out })
286
+ : await enrollServer({ url: normalizeUrl(url), config, print: out })
287
+ out(t('connect.done'))
288
+ return state ? 0 : 1
289
+ }
290
+
291
+ if (command === 'daemon') {
292
+ const urls = rest.filter((arg) => !arg.startsWith('--'))
293
+ const targets = urls.length ? urls.map(normalizeUrl) : openStore().servers()
294
+ if (!targets.length) {
295
+ process.stderr.write(t('err.nothingToServe') + '\n')
296
+ return 2
297
+ }
298
+ const controller = new AbortController()
299
+ const stop = () => {
300
+ out(t('daemon.stopping'))
301
+ // фоновые процессы умирают вместе с демоном: иначе после остановки
302
+ // сервиса на машине остаются работать задания, о которых никто не знает
303
+ try {
304
+ jobPlugin.stopAll()
305
+ } catch {}
306
+ controller.abort()
307
+ }
308
+ process.on('SIGINT', stop)
309
+ process.on('SIGTERM', stop)
310
+ // SIGHUP обрабатывает сам раннер: он же будит спящий цикл и переобъявляет
311
+ // API. Здесь ничего не делаем, чтобы не было двух источников правды.
312
+ const plugins = await loadPlugins({ dir: join(home(), 'plugins'), log: (level, message) => out(message) })
313
+ out(t('version.line', { version: VERSION, proto: PROTO }))
314
+ out(t('daemon.servers', { servers: targets.join(', ') }))
315
+ out(t('daemon.plugins', { plugins: plugins.map((plugin) => plugin.api.name).join(', ') }))
316
+ const shell = process.env.DSHNC_SHELL ?? '/bin/sh'
317
+ await Promise.all(targets.map((url) => runServer({
318
+ url,
319
+ config,
320
+ print: (line) => out(line),
321
+ signal: controller.signal,
322
+ shell,
323
+ plugins,
324
+ }).catch((error) => {
325
+ if (error.message !== 'aborted') process.stderr.write(`${url}: ${error.message}\n`)
326
+ })))
327
+ return 0
328
+ }
329
+
330
+ process.stderr.write(t('cmd.unknown', { command }) + '\n\n' + HELP())
331
+ return 2
332
+ }
333
+
334
+ main(process.argv.slice(2)).then((code) => process.exit(code)).catch((error) => {
335
+ process.stderr.write(t('err.generic', { message: error.message }) + '\n')
336
+ process.exit(1)
337
+ })