@prohost/cli 0.4.1 → 0.5.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Client-side liveness for the event-stream socket.
3
+ *
4
+ * The server pings every 30 seconds and closes a socket that has not answered
5
+ * for 60. That contract only helps when the close actually arrives. A laptop
6
+ * that sleeps loses the server's FIN, the kernel keeps the TCP connection in
7
+ * ESTABLISHED, and `ws` — which reacts to `close` and `error` alone — sees a
8
+ * socket that is open, quiet, and never coming back. One paired agent sat on
9
+ * exactly that for three weeks: connected as far as the client could tell,
10
+ * unreachable as far as the server was concerned, and nothing on either side
11
+ * with a reason to say so.
12
+ *
13
+ * So the client keeps its own deadline. Every frame the server sends — pings
14
+ * included — is a proof of life; ninety seconds without one (three missed
15
+ * pings, comfortably past the server's own 60 s reaper) means the connection
16
+ * is dead whatever the kernel thinks, and the socket is torn down locally so
17
+ * the ordinary reconnect path runs. TCP keepalive is switched on as a second,
18
+ * kernel-level probe, and a periodic heartbeat line makes a stalled daemon
19
+ * visible in its log instead of indistinguishable from a healthy quiet one.
20
+ */
21
+ /** Silence tolerated before the socket is declared dead: three missed pings. */
22
+ export declare const PING_LIVENESS_MS = 90000;
23
+ /** Delay before the kernel starts sending TCP keepalive probes on an idle socket. */
24
+ export declare const TCP_KEEPALIVE_DELAY_MS = 30000;
25
+ /** How often a connected harness writes its "still here" line. */
26
+ export declare const HEARTBEAT_LOG_INTERVAL_MS = 300000;
27
+ /**
28
+ * The slice of a `ws` client this module touches — narrow so that the test
29
+ * doubles, which implement only what the harness calls, satisfy it too.
30
+ */
31
+ export interface LivenessSocket {
32
+ close(): void;
33
+ /** `ws` has it; the handwritten test doubles may not. */
34
+ terminate?(): void;
35
+ }
36
+ export interface LivenessOptions {
37
+ /** Silence window before {@link ServerLiveness.onSilence} fires. */
38
+ timeoutMs?: number;
39
+ /** Cadence of {@link ServerLiveness.onHeartbeat}. Tests shorten it. */
40
+ heartbeatIntervalMs?: number;
41
+ /** Clock, injectable so a test can age the connection without waiting. */
42
+ now?: () => number;
43
+ /** Called once when the window elapses with no frame. Receives the silence in ms. */
44
+ onSilence: (silentMs: number) => void;
45
+ /** Called on each heartbeat tick with the liveness state of the moment. */
46
+ onHeartbeat?: (state: LivenessState) => void;
47
+ }
48
+ export interface LivenessState {
49
+ /** Milliseconds since the last frame of any kind from the server. */
50
+ sinceLastFrameMs: number;
51
+ /** Milliseconds since the last `ping` — `undefined` before the first one. */
52
+ sinceLastPingMs: number | undefined;
53
+ }
54
+ /**
55
+ * Tracks the server's frames for one socket lifetime and fires when they stop.
56
+ *
57
+ * Owns two timers: the silence deadline, re-armed on every frame, and the
58
+ * heartbeat interval. Both are cleared by {@link stop}, which the socket's
59
+ * `close` handler must call — a timer left running after a reconnect would
60
+ * tear down the *next* socket for the previous one's silence.
61
+ */
62
+ export declare class ServerLiveness {
63
+ private readonly timeoutMs;
64
+ private readonly heartbeatIntervalMs;
65
+ private readonly now;
66
+ private readonly onSilence;
67
+ private readonly onHeartbeat;
68
+ private lastFrameAt;
69
+ private lastPingAt;
70
+ private deadline;
71
+ private heartbeat;
72
+ private stopped;
73
+ constructor(options: LivenessOptions);
74
+ /**
75
+ * Arm the deadline. Called on `open`: the handshake completing is itself the
76
+ * first proof of life, and `ws` has its own timeout for a handshake that
77
+ * never completes.
78
+ */
79
+ start(): void;
80
+ /** Record a frame from the server. Any frame counts; a `ping` is also remembered as one. */
81
+ observe(frameType: unknown): void;
82
+ /** Release both timers. Idempotent; safe to call from `close` however the socket ended. */
83
+ stop(): void;
84
+ state(): LivenessState;
85
+ private arm;
86
+ }
87
+ /**
88
+ * Tear a socket down without the closing handshake.
89
+ *
90
+ * `close()` sends a close frame and then waits for the peer's — on a half-open
91
+ * connection that never comes, and `ws` only gives up on it after its own 30 s
92
+ * timer. `terminate()` destroys the underlying socket immediately, which is
93
+ * the right thing for a peer we have already decided is not there. The test
94
+ * doubles have no `terminate`, so fall back to `close`.
95
+ */
96
+ export declare function terminateSocket(ws: LivenessSocket): void;
97
+ /**
98
+ * Switch on TCP keepalive for the connection under a `ws` client.
99
+ *
100
+ * Reaches into `ws`'s private `_socket` — there is no public accessor — so
101
+ * everything is guarded: a build of `ws` that renames it, or a test double
102
+ * that has none, simply gets no keepalive rather than a crash on `open`.
103
+ * Returns whether the kernel-level probe was actually enabled.
104
+ */
105
+ export declare function enableTcpKeepAlive(ws: unknown, delayMs?: number): boolean;
106
+ /** Whole seconds, for log lines. */
107
+ export declare function seconds(ms: number): number;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Client-side liveness for the event-stream socket.
3
+ *
4
+ * The server pings every 30 seconds and closes a socket that has not answered
5
+ * for 60. That contract only helps when the close actually arrives. A laptop
6
+ * that sleeps loses the server's FIN, the kernel keeps the TCP connection in
7
+ * ESTABLISHED, and `ws` — which reacts to `close` and `error` alone — sees a
8
+ * socket that is open, quiet, and never coming back. One paired agent sat on
9
+ * exactly that for three weeks: connected as far as the client could tell,
10
+ * unreachable as far as the server was concerned, and nothing on either side
11
+ * with a reason to say so.
12
+ *
13
+ * So the client keeps its own deadline. Every frame the server sends — pings
14
+ * included — is a proof of life; ninety seconds without one (three missed
15
+ * pings, comfortably past the server's own 60 s reaper) means the connection
16
+ * is dead whatever the kernel thinks, and the socket is torn down locally so
17
+ * the ordinary reconnect path runs. TCP keepalive is switched on as a second,
18
+ * kernel-level probe, and a periodic heartbeat line makes a stalled daemon
19
+ * visible in its log instead of indistinguishable from a healthy quiet one.
20
+ */
21
+ /** Silence tolerated before the socket is declared dead: three missed pings. */
22
+ export const PING_LIVENESS_MS = 90_000;
23
+ /** Delay before the kernel starts sending TCP keepalive probes on an idle socket. */
24
+ export const TCP_KEEPALIVE_DELAY_MS = 30_000;
25
+ /** How often a connected harness writes its "still here" line. */
26
+ export const HEARTBEAT_LOG_INTERVAL_MS = 300_000;
27
+ /**
28
+ * Tracks the server's frames for one socket lifetime and fires when they stop.
29
+ *
30
+ * Owns two timers: the silence deadline, re-armed on every frame, and the
31
+ * heartbeat interval. Both are cleared by {@link stop}, which the socket's
32
+ * `close` handler must call — a timer left running after a reconnect would
33
+ * tear down the *next* socket for the previous one's silence.
34
+ */
35
+ export class ServerLiveness {
36
+ timeoutMs;
37
+ heartbeatIntervalMs;
38
+ now;
39
+ onSilence;
40
+ onHeartbeat;
41
+ lastFrameAt;
42
+ lastPingAt;
43
+ deadline;
44
+ heartbeat;
45
+ stopped = false;
46
+ constructor(options) {
47
+ this.timeoutMs = options.timeoutMs ?? PING_LIVENESS_MS;
48
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_LOG_INTERVAL_MS;
49
+ this.now = options.now ?? Date.now;
50
+ this.onSilence = options.onSilence;
51
+ this.onHeartbeat = options.onHeartbeat;
52
+ this.lastFrameAt = this.now();
53
+ }
54
+ /**
55
+ * Arm the deadline. Called on `open`: the handshake completing is itself the
56
+ * first proof of life, and `ws` has its own timeout for a handshake that
57
+ * never completes.
58
+ */
59
+ start() {
60
+ if (this.stopped)
61
+ return;
62
+ this.lastFrameAt = this.now();
63
+ this.arm();
64
+ if (this.onHeartbeat && this.heartbeat === undefined) {
65
+ this.heartbeat = setInterval(() => this.onHeartbeat?.(this.state()), this.heartbeatIntervalMs);
66
+ this.heartbeat.unref?.();
67
+ }
68
+ }
69
+ /** Record a frame from the server. Any frame counts; a `ping` is also remembered as one. */
70
+ observe(frameType) {
71
+ if (this.stopped)
72
+ return;
73
+ this.lastFrameAt = this.now();
74
+ if (frameType === 'ping')
75
+ this.lastPingAt = this.lastFrameAt;
76
+ if (this.deadline !== undefined)
77
+ this.arm();
78
+ }
79
+ /** Release both timers. Idempotent; safe to call from `close` however the socket ended. */
80
+ stop() {
81
+ this.stopped = true;
82
+ if (this.deadline !== undefined)
83
+ clearTimeout(this.deadline);
84
+ if (this.heartbeat !== undefined)
85
+ clearInterval(this.heartbeat);
86
+ this.deadline = undefined;
87
+ this.heartbeat = undefined;
88
+ }
89
+ state() {
90
+ const now = this.now();
91
+ return {
92
+ sinceLastFrameMs: now - this.lastFrameAt,
93
+ sinceLastPingMs: this.lastPingAt === undefined ? undefined : now - this.lastPingAt,
94
+ };
95
+ }
96
+ arm() {
97
+ if (this.deadline !== undefined)
98
+ clearTimeout(this.deadline);
99
+ this.deadline = setTimeout(() => {
100
+ this.deadline = undefined;
101
+ if (this.stopped)
102
+ return;
103
+ // Stop before firing: the callback tears the socket down, and the close
104
+ // handler that follows calls stop() again — harmless, but the deadline
105
+ // must not be re-armed by a frame that races the teardown.
106
+ this.stop();
107
+ this.onSilence(this.now() - this.lastFrameAt);
108
+ }, this.timeoutMs);
109
+ this.deadline.unref?.();
110
+ }
111
+ }
112
+ /**
113
+ * Tear a socket down without the closing handshake.
114
+ *
115
+ * `close()` sends a close frame and then waits for the peer's — on a half-open
116
+ * connection that never comes, and `ws` only gives up on it after its own 30 s
117
+ * timer. `terminate()` destroys the underlying socket immediately, which is
118
+ * the right thing for a peer we have already decided is not there. The test
119
+ * doubles have no `terminate`, so fall back to `close`.
120
+ */
121
+ export function terminateSocket(ws) {
122
+ try {
123
+ if (typeof ws.terminate === 'function')
124
+ ws.terminate();
125
+ else
126
+ ws.close();
127
+ }
128
+ catch {
129
+ /* already gone */
130
+ }
131
+ }
132
+ /**
133
+ * Switch on TCP keepalive for the connection under a `ws` client.
134
+ *
135
+ * Reaches into `ws`'s private `_socket` — there is no public accessor — so
136
+ * everything is guarded: a build of `ws` that renames it, or a test double
137
+ * that has none, simply gets no keepalive rather than a crash on `open`.
138
+ * Returns whether the kernel-level probe was actually enabled.
139
+ */
140
+ export function enableTcpKeepAlive(ws, delayMs = TCP_KEEPALIVE_DELAY_MS) {
141
+ try {
142
+ const socket = ws
143
+ ._socket;
144
+ if (!socket || typeof socket.setKeepAlive !== 'function')
145
+ return false;
146
+ socket.setKeepAlive(true, delayMs);
147
+ return true;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /** Whole seconds, for log lines. */
154
+ export function seconds(ms) {
155
+ return Math.round(ms / 1000);
156
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Best-effort "a newer build exists" notice for `agent run`.
3
+ *
4
+ * A daemon installed with `npm i -g` never upgrades itself, and nothing in the
5
+ * product can tell an operator which build their machine is on: the one that
6
+ * sat on a dead socket for three weeks was two minor versions behind the
7
+ * registry the whole time. The auth frame now reports the version server-side;
8
+ * this is the client-side half — one registry lookup at startup, printed as a
9
+ * single line when it finds something newer, and silent on every other
10
+ * outcome. It never delays the connection and cannot fail startup: the
11
+ * registry being slow, down, or answering nonsense is not the operator's
12
+ * problem and must not become one.
13
+ */
14
+ export declare const NPM_LATEST_URL = "https://registry.npmjs.org/@prohost/cli/latest";
15
+ /** Longest the registry gets. The socket does not wait on this. */
16
+ export declare const UPGRADE_CHECK_TIMEOUT_MS = 3000;
17
+ export interface UpgradeCheckOptions {
18
+ /** The running build. Defaults to {@link CLI_VERSION}; tests pin it. */
19
+ currentVersion?: string;
20
+ fetchImpl?: typeof fetch;
21
+ timeoutMs?: number;
22
+ registryUrl?: string;
23
+ }
24
+ /**
25
+ * Compare two `MAJOR.MINOR.PATCH[-prerelease]` strings the way npm orders them
26
+ * for the cases this check meets: numeric identifiers compared as numbers,
27
+ * missing ones as zero, and a prerelease sorting *before* its release. Returns
28
+ * a negative number when `a` is older, positive when newer, `0` when equal.
29
+ * Not a full semver implementation — build metadata and prerelease ordering
30
+ * are not needed to answer "is the registry ahead of me".
31
+ */
32
+ export declare function compareVersions(a: string, b: string): number;
33
+ /**
34
+ * Ask the registry for the latest published version.
35
+ *
36
+ * Resolves to the version string when it is strictly newer than the running
37
+ * build, and `undefined` for everything else — up to date, ahead of the
38
+ * registry (a local build), a timeout, a non-2xx, a malformed body. It never
39
+ * rejects.
40
+ */
41
+ export declare function newerVersionAvailable(options?: UpgradeCheckOptions): Promise<string | undefined>;
42
+ /** The one line printed when a newer build exists. */
43
+ export declare function upgradeNotice(latest: string, current?: string): string;
44
+ /**
45
+ * Run the check without waiting for it: prints the notice if and when one is
46
+ * due, and otherwise does nothing. The returned promise is for tests; callers
47
+ * on the startup path drop it.
48
+ */
49
+ export declare function announceUpgradeIfAvailable(write: (line: string) => void, options?: UpgradeCheckOptions): Promise<void>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Best-effort "a newer build exists" notice for `agent run`.
3
+ *
4
+ * A daemon installed with `npm i -g` never upgrades itself, and nothing in the
5
+ * product can tell an operator which build their machine is on: the one that
6
+ * sat on a dead socket for three weeks was two minor versions behind the
7
+ * registry the whole time. The auth frame now reports the version server-side;
8
+ * this is the client-side half — one registry lookup at startup, printed as a
9
+ * single line when it finds something newer, and silent on every other
10
+ * outcome. It never delays the connection and cannot fail startup: the
11
+ * registry being slow, down, or answering nonsense is not the operator's
12
+ * problem and must not become one.
13
+ */
14
+ import { CLI_VERSION, USER_AGENT } from './version.js';
15
+ export const NPM_LATEST_URL = 'https://registry.npmjs.org/@prohost/cli/latest';
16
+ /** Longest the registry gets. The socket does not wait on this. */
17
+ export const UPGRADE_CHECK_TIMEOUT_MS = 3_000;
18
+ /**
19
+ * Compare two `MAJOR.MINOR.PATCH[-prerelease]` strings the way npm orders them
20
+ * for the cases this check meets: numeric identifiers compared as numbers,
21
+ * missing ones as zero, and a prerelease sorting *before* its release. Returns
22
+ * a negative number when `a` is older, positive when newer, `0` when equal.
23
+ * Not a full semver implementation — build metadata and prerelease ordering
24
+ * are not needed to answer "is the registry ahead of me".
25
+ */
26
+ export function compareVersions(a, b) {
27
+ const parse = (v) => {
28
+ const [core = '', prerelease] = v.trim().replace(/^v/, '').split('-', 2);
29
+ const parts = core.split('.').map((p) => {
30
+ const n = Number.parseInt(p, 10);
31
+ return Number.isFinite(n) ? n : 0;
32
+ });
33
+ return { parts, prerelease: prerelease !== undefined && prerelease !== '' };
34
+ };
35
+ const left = parse(a);
36
+ const right = parse(b);
37
+ for (let i = 0; i < Math.max(left.parts.length, right.parts.length); i += 1) {
38
+ const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
39
+ if (diff !== 0)
40
+ return diff;
41
+ }
42
+ if (left.prerelease === right.prerelease)
43
+ return 0;
44
+ return left.prerelease ? -1 : 1;
45
+ }
46
+ /**
47
+ * Ask the registry for the latest published version.
48
+ *
49
+ * Resolves to the version string when it is strictly newer than the running
50
+ * build, and `undefined` for everything else — up to date, ahead of the
51
+ * registry (a local build), a timeout, a non-2xx, a malformed body. It never
52
+ * rejects.
53
+ */
54
+ export async function newerVersionAvailable(options = {}) {
55
+ const current = options.currentVersion ?? CLI_VERSION;
56
+ const fetchImpl = options.fetchImpl ?? fetch;
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? UPGRADE_CHECK_TIMEOUT_MS);
59
+ timer.unref?.();
60
+ try {
61
+ const response = await fetchImpl(options.registryUrl ?? NPM_LATEST_URL, {
62
+ method: 'GET',
63
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
64
+ signal: controller.signal,
65
+ });
66
+ if (response.status < 200 || response.status >= 300)
67
+ return undefined;
68
+ const body = await response.json();
69
+ const latest = body && typeof body === 'object' ? body.version : undefined;
70
+ if (typeof latest !== 'string' || !/^\d+\.\d+\.\d+/.test(latest))
71
+ return undefined;
72
+ return compareVersions(latest, current) > 0 ? latest : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+ /** The one line printed when a newer build exists. */
82
+ export function upgradeNotice(latest, current = CLI_VERSION) {
83
+ return `⬆ @prohost/cli ${latest} is available (running ${current}) — npm i -g @prohost/cli`;
84
+ }
85
+ /**
86
+ * Run the check without waiting for it: prints the notice if and when one is
87
+ * due, and otherwise does nothing. The returned promise is for tests; callers
88
+ * on the startup path drop it.
89
+ */
90
+ export function announceUpgradeIfAvailable(write, options = {}) {
91
+ return newerVersionAvailable(options)
92
+ .then((latest) => {
93
+ if (latest)
94
+ write(upgradeNotice(latest, options.currentVersion ?? CLI_VERSION));
95
+ })
96
+ .catch(() => {
97
+ /* never surfaces — see the module comment */
98
+ });
99
+ }
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.4.1";
10
+ export declare const CLI_VERSION = "0.5.0";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.4.1";
12
+ export declare const USER_AGENT = "prohost-cli/0.5.0";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.4.1';
10
+ export const CLI_VERSION = '0.5.0';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",