@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/README.md
CHANGED
|
@@ -1,3 +1,277 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-node-client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
})
|