dsh-foreman-node 0.1.0 → 0.2.0

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/index.d.ts CHANGED
@@ -1,6 +1,62 @@
1
1
  import { Frame, FrameAttachment, FrameDedupe, Root } from "./protocol.js";
2
- import { Context } from "@deepseek-ai/cordis";
2
+ import { Context, Volatile } from "@deepseek-ai/cordis";
3
+ import { IncomingMessage, ServerResponse } from "node:http";
3
4
  import { Agent } from "@deepseek-ai/dsh-agent";
5
+ /**
6
+ * The device's configuration shape, with no Node imports.
7
+ *
8
+ * This module exists so the browser page and the Node plugin can share one definition. The page must not import
9
+ * `index.ts`: that file pulls in `node:net`, `node:child_process` and the job-session code, and a browser bundle that
10
+ * reaches them fails to load at boot — which is exactly how the first 0.2.0 build broke.
11
+ */
12
+ /** One project this device can work on: the hub's logical name and this machine's own absolute path. */
13
+ interface NodeRoot {
14
+ project: string;
15
+ path: string;
16
+ }
17
+ /**
18
+ * How the device reaches a hub it cannot reach directly.
19
+ *
20
+ * The hub listens on loopback only — a deliberate constraint, because the ledger is the one thing worth protecting —
21
+ * so a device on another machine reaches it through a tunnel. With `enabled` the device opens that tunnel itself and
22
+ * dials its own end of it; `url` is then unused, not overridden, so switching back to a direct address restores it.
23
+ */
24
+ interface SshConfig {
25
+ /** Open a tunnel to the hub instead of dialling `url` directly. */
26
+ enabled: boolean;
27
+ /** The hub machine: an address or a `~/.ssh/config` alias. */
28
+ host: string;
29
+ /** The hub machine's SSH port. */
30
+ port: number;
31
+ /** The SSH user on the hub machine. */
32
+ user: string;
33
+ /** The private key this device authenticates with. The configuration page lists these from `~/.ssh`. */
34
+ keyPath: string;
35
+ /** The port the hub listens on, on the hub machine. */
36
+ remotePort: number;
37
+ /** The local port the tunnel listens on; the device dials this. */
38
+ localPort: number;
39
+ }
40
+ /** What the `dsh-foreman-node` entry in a profile's patch configures. */
41
+ interface NodeConfig {
42
+ /** The hub's stable address; the device dials out to it. Blank until someone fills it in. */
43
+ url: string;
44
+ /** This device's id, as registered on the hub with `Devices.add`. Blank until someone fills it in. */
45
+ deviceId: string;
46
+ /** This device's token; sent in the hello and nowhere else. Blank until someone fills it in. */
47
+ token: string;
48
+ /** The projects this device can work on. */
49
+ roots: NodeRoot[];
50
+ /** How often to tell the hub this device is alive, in milliseconds. */
51
+ heartbeatMs: number;
52
+ /** Delay before the first reconnect attempt, in milliseconds. */
53
+ reconnectMs: number;
54
+ /** How to reach a hub that is not directly reachable. */
55
+ ssh: SshConfig;
56
+ }
57
+ /** The SSH defaults, spread over whatever the host hands in, so a partial block is still complete. */
58
+ declare const SSH_DEFAULTS: SshConfig;
59
+ declare function configured(config: object | undefined): NodeConfig;
4
60
  /** What a socket has to offer; the global `WebSocket` satisfies it, and tests supply an object that does too. */
5
61
  interface SocketLike {
6
62
  readyState: number;
@@ -77,6 +133,146 @@ declare class NodeClient {
77
133
  accepted(assignmentId: string): void;
78
134
  private scheduleReconnect;
79
135
  }
136
+ /** Where the tunnel is, in the only terms a person needs: not yet, trying, working, broken. */
137
+ type TunnelState = 'idle' | 'connecting' | 'connected' | 'failed';
138
+ /** The minimum of a spawned process this class uses, so a test never starts a real ssh. */
139
+ interface TunnelChild {
140
+ killed: boolean;
141
+ kill(signal?: NodeJS.Signals): boolean;
142
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
143
+ on(event: 'error', listener: (error: Error) => void): unknown;
144
+ stderr?: {
145
+ on(event: 'data', listener: (chunk: Buffer) => void): unknown;
146
+ } | null;
147
+ }
148
+ type SpawnTunnel = (argv: readonly string[]) => TunnelChild;
149
+ /** Resolves whether something accepts a TCP connection on a local port. */
150
+ type ProbePort = (port: number) => Promise<boolean>;
151
+ interface TunnelInput {
152
+ ssh: SshConfig;
153
+ spawn?: SpawnTunnel;
154
+ probe?: ProbePort;
155
+ /** Called on every state change, so the page and the log see the same transitions the client acts on. */
156
+ onState?: (state: TunnelState, failure?: string) => void;
157
+ log?: (text: string) => void;
158
+ /** Delay before the first retry, in milliseconds; doubles each time up to `maxRetryMs`. */
159
+ retryMs?: number;
160
+ maxRetryMs?: number;
161
+ }
162
+ /** The argv of the forwarding process, in order, exactly as the tests pin it. */
163
+ declare function tunnelArgs(ssh: SshConfig): string[];
164
+ /**
165
+ * Keeps one forwarding process alive, reporting what it is doing.
166
+ *
167
+ * A retry is always a *new* process: an ssh that exited cannot be revived, and reusing the object would report a
168
+ * state no process is in. `stop()` is idempotent and leaves nothing behind, including a pending retry timer.
169
+ */
170
+ declare class Tunnel {
171
+ private readonly input;
172
+ private current;
173
+ private lastFailure;
174
+ private child;
175
+ private timer;
176
+ /** The pending escalation from SIGTERM to SIGKILL after `stop()`. */
177
+ private killTimer;
178
+ /** Whether the local port was free when the current attempt spawned; only then can an answer be our own forward. */
179
+ private portWasFree;
180
+ /**
181
+ * The child that has actually exited, if any.
182
+ *
183
+ * `ChildProcess.killed` means "a signal was sent", not "the process is gone", so it cannot answer "is it still
184
+ * running" — and using it that way makes the SIGKILL escalation unreachable. The exit event is the only truth.
185
+ */
186
+ private exitedChild;
187
+ /** A generation counter, so a late callback from a superseded attempt cannot overwrite the current state. */
188
+ private generation;
189
+ private retryMs;
190
+ private readonly maxRetryMs;
191
+ private readonly spawn;
192
+ private readonly probe;
193
+ constructor(input: TunnelInput);
194
+ state(): TunnelState;
195
+ /** Why the last attempt failed, already rewritten into something a person can act on. */
196
+ failure(): string | undefined;
197
+ /** Start the first attempt, or do nothing if one is already running. */
198
+ start(): void;
199
+ /**
200
+ * End the process and any pending retry; safe to call more than once.
201
+ *
202
+ * SIGTERM first, then SIGKILL: an ssh that ignores or is slow to handle the polite signal would otherwise outlive
203
+ * the plugin, keep `localPort` bound, and make the next activation's first attempt fail on a port it still owns.
204
+ */
205
+ stop(): void;
206
+ /** One attempt: check the port is free, spawn, wait for the local port, then either stay connected or fail. */
207
+ private attempt;
208
+ /**
209
+ * Poll the local port until it answers or the attempt is superseded; a first answer means the forward works.
210
+ *
211
+ * Reaching `connected` deliberately does NOT mark the attempt settled. The process is still running, and it is
212
+ * exactly the exit that follows a working tunnel — a dropped link, a restarted hub — that has to reach `fail` and
213
+ * schedule a retry. Marking it settled here would leave a device that connects once and then silently stops
214
+ * taking work, which is the hardest failure to notice.
215
+ */
216
+ private waitReady;
217
+ /** Record a failure, release the process, and schedule the next attempt with a growing delay. */
218
+ private fail;
219
+ private set;
220
+ }
221
+ /** One key a person may choose: never its contents, only what tells it apart from the others. */
222
+ interface SshKey {
223
+ /** The filename, such as `id_ed25519`. */
224
+ name: string;
225
+ /** Its absolute path, which is what the configuration stores. */
226
+ path: string;
227
+ /** The trailing comment of the public half, usually `user@machine`; empty when it has none. */
228
+ comment: string;
229
+ }
230
+ /**
231
+ * Lists the usable private keys in a directory.
232
+ *
233
+ * A key counts as usable only when its public half is beside it: that is how `ssh-keygen` writes them, and a private
234
+ * key without one is either half-deleted or was never finished, so offering it would only fail later at the tunnel.
235
+ * Anything unreadable is skipped rather than thrown, because a permissions problem on one file must not empty the list.
236
+ */
237
+ declare function listKeys(directory: string): SshKey[];
238
+ /** Runs one command and reports its exit status with its output; injectable so a test never starts ssh. */
239
+ type RunCommand = (argv: readonly string[]) => Promise<{
240
+ code: number | null;
241
+ output: string;
242
+ }>;
243
+ /** The connection the page is testing, exactly the four fields it collects. */
244
+ interface TestInput {
245
+ host: string;
246
+ port: number;
247
+ user: string;
248
+ keyPath: string;
249
+ }
250
+ /**
251
+ * The argv of an authentication test.
252
+ *
253
+ * It asks for a command that does nothing (`true`) rather than opening a forward, because the question is only
254
+ * "does this key authenticate", and `BatchMode=yes` keeps it from stopping to ask for a passphrase nobody can type.
255
+ */
256
+ declare function testArgs({ host, port, user, keyPath }: TestInput): string[];
257
+ interface RouteHost {
258
+ /** The web server service: the only thing this module needs from the host. */
259
+ register(route: {
260
+ kind: 'prefix';
261
+ path: string;
262
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
263
+ }): () => void;
264
+ /** The port the web server listens on, used by the same-origin check. */
265
+ port: number;
266
+ /** The scheme the web server serves, so a TLS-terminated host's `https://` origin is accepted. */
267
+ protocol?: string;
268
+ }
269
+ /**
270
+ * Registers the routes and returns their disposer.
271
+ *
272
+ * `listKeys` and `run` are injectable so tests can answer without a filesystem or a child process; production passes
273
+ * a directory and the real runner.
274
+ */
275
+ declare function registerNodeRoutes(host: RouteHost, directory: string, run?: RunCommand): () => void;
80
276
  /**
81
277
  * The `foreman_report` tool a device registers on a job Session.
82
278
  *
@@ -178,42 +374,63 @@ declare class JobSessions {
178
374
  reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void;
179
375
  }
180
376
  /** Version reported in the hello, so the hub can tell which device build is talking. */
181
- declare const VERSION = "0.1.0";
182
- /** One project this device can work on: the hub's logical name and this machine's own absolute path. */
183
- interface NodeRoot {
184
- project: string;
185
- path: string;
186
- }
187
- /** What the `dsh-foreman-node` entry in a profile's patch configures. */
188
- interface NodeConfig {
189
- /** The hub's stable address; the device dials out to it. */
190
- url: string;
191
- /** This device's id, as registered on the hub with `Devices.add`. */
192
- deviceId: string;
193
- /** This device's token; sent in the hello and nowhere else. */
194
- token: string;
195
- /** The projects this device can work on. */
196
- roots: NodeRoot[];
197
- /** How often to tell the hub this device is alive, in milliseconds. */
198
- heartbeatMs: number;
199
- /** Delay before the first reconnect attempt, in milliseconds. */
200
- reconnectMs: number;
201
- }
377
+ declare const VERSION = "0.2.0";
378
+ /**
379
+ * Why every field is volatile.
380
+ *
381
+ * Not for hot reload: it is the switch that makes the entry visible to DSH's settings system at all. `dsh-settings`
382
+ * skips every plugin entry whose `volatileForm(schema)` is undefined, and that only finds fields reachable through a
383
+ * volatile ancestor. Without it the configuration page has no form to render and sits on "reading configuration"
384
+ * forever — no fields, no test button, no save.
385
+ *
386
+ * A volatile field arrives wrapped, so its value is read with `.get()`; `configured()` is what unwraps a whole
387
+ * `NodeConfig` for the code that would rather not know. Marking the object instead of each field is not an option:
388
+ * `.volatile()` on the object replaces every default with `{}`, so `Config({})` loses all of them.
389
+ */
390
+ /**
391
+ * What `apply` actually receives: every field wrapped, since each one is volatile.
392
+ *
393
+ * Declared as an alias rather than inferred so the plugin's public signature does not name a `schemastery` symbol the
394
+ * emitted declaration cannot reference portably.
395
+ */
396
+ type ConfigInput = { [K in keyof NodeConfig]: NodeConfig[K] | Volatile<NodeConfig[K]>; };
202
397
  /**
203
- * Configuration of one device, from the `dsh-foreman-node` entry in the profile's patch.
398
+ * The schema, asserted rather than annotated.
204
399
  *
205
- * The annotation names the schema type through the global `Schemastery` namespace rather than letting it be inferred:
206
- * an inferred type here names a `cosmokit` symbol the emitted declaration cannot reference portably.
400
+ * An annotation makes the declaration portable but cannot express what a volatile field does to a field's type, so the
401
+ * mismatch moves into the annotation. The assertion keeps the published `.d.ts` free of `schemastery` internals and
402
+ * puts the shape in one place; `configured()` is what re-establishes the real types at every read.
207
403
  */
208
- declare const Config: Schemastery<NodeConfig>;
404
+ declare const Typed: Schemastery<NodeConfig>;
405
+ /**
406
+ * What this configuration still needs before the device can dial its hub, or undefined when it is complete.
407
+ *
408
+ * A missing value is reported by name so an operator can fill it in without reading the source; a value that is only
409
+ * whitespace counts as missing, because a device that dials a blank address retries forever and looks merely offline.
410
+ */
411
+ /**
412
+ * The address this device dials.
413
+ *
414
+ * With the tunnel on, that is the local end of it: `url` names the hub at its own address, which the tunnel exists to
415
+ * make reachable, so dialling `url` would bypass the tunnel entirely and look like an offline device. Keeping the two
416
+ * cases in one function is what stops `apply` from picking the wrong one.
417
+ */
418
+ declare function hubUrlOf(config: NodeConfig): string;
419
+ declare function missingConfiguration(config: NodeConfig): string | undefined;
209
420
  declare const name = "dsh-foreman-node";
210
421
  /** DSH services a job needs; all are the host's own, and none is a foreman one. */
211
422
  declare const inject: string[];
212
423
  /**
213
424
  * Start the device.
214
425
  *
426
+ * A package that has just been installed has its row in the profile patch with no values in it yet. Activating
427
+ * cleanly and saying what is still missing keeps the whole profile healthy; throwing here is what made a fresh
428
+ * install report the profile as broken.
429
+ *
215
430
  * The plugin returns once the connection is attempted, not once it succeeds: a device must boot and stay usable
216
431
  * while its hub is unreachable, and the client keeps retrying in the background.
217
432
  */
218
- declare function apply(ctx: Context, config: NodeConfig): Promise<void>;
219
- export { Config, type Frame, type FrameAttachment, JobSessions, NodeClient, NodeConfig, NodeRoot, REPORT_STATES, type Root, VERSION, apply, foremanReportTool, inject, name };
433
+ declare function apply(ctx: Context, raw: ConfigInput): Promise<void>;
434
+ /** The directory the key list reads; `~/.ssh` on a machine where the current user is the one configuring it. */
435
+ declare function sshDirectory(): string;
436
+ export { Typed as Config, ConfigInput, type Frame, type FrameAttachment, JobSessions, NodeClient, type NodeConfig, type NodeRoot, REPORT_STATES, type Root, SSH_DEFAULTS, type SshConfig, Tunnel, VERSION, apply, configured, foremanReportTool, hubUrlOf, inject, listKeys, missingConfiguration, name, registerNodeRoutes, sshDirectory, testArgs, tunnelArgs };