@prohost/cli 0.1.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,99 @@
1
+ /**
2
+ * HMAC verification for events arriving over the websocket.
3
+ *
4
+ * A run event makes this CLI execute a local command, so the frame has to be
5
+ * proven to come from ProhostAI before it is acted on. HTTPS webhook receivers
6
+ * verify the HMAC over the raw request body; the websocket carries the payload
7
+ * as parsed JSON instead, so we re-serialize it in the exact form the server
8
+ * signed and compare.
9
+ *
10
+ * The server signs ``json.dumps(envelope, sort_keys=True, default=str)``
11
+ * (`shared/webhooks_outbound/signing.py::canonical_body`), which means:
12
+ * recursively sorted keys, Python's ``", "`` / ``": "`` separators, and
13
+ * ``ensure_ascii=True`` (every character outside ``0x20``–``0x7e`` escaped as
14
+ * ``\uXXXX``, non-BMP as a UTF-16 surrogate pair — which is what JS strings
15
+ * already hold). :func:`canonicalJson` reproduces that byte-for-byte.
16
+ *
17
+ * Known limit: JSON has one number type but Python does not. A float that
18
+ * round-trips through ``JSON.parse`` as an integer (``1.0`` → ``1``)
19
+ * re-serializes as ``1`` and fails verification. ProhostAI event payloads carry
20
+ * ids, timestamps, and text — no bare floats — so this has no practical effect;
21
+ * it is called out because the failure mode would otherwise look like tampering.
22
+ */
23
+ import { createHmac, timingSafeEqual } from 'node:crypto';
24
+ const ESCAPES = {
25
+ '\\': '\\\\',
26
+ '"': '\\"',
27
+ '\b': '\\b',
28
+ '\f': '\\f',
29
+ '\n': '\\n',
30
+ '\r': '\\r',
31
+ '\t': '\\t',
32
+ };
33
+ /** Serialize a string the way Python's ``json.dumps(ensure_ascii=True)`` does. */
34
+ function encodeString(value) {
35
+ let out = '"';
36
+ for (let i = 0; i < value.length; i += 1) {
37
+ const ch = value[i];
38
+ const escaped = ESCAPES[ch];
39
+ if (escaped !== undefined) {
40
+ out += escaped;
41
+ continue;
42
+ }
43
+ const code = value.charCodeAt(i);
44
+ if (code < 0x20 || code > 0x7e) {
45
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
46
+ continue;
47
+ }
48
+ out += ch;
49
+ }
50
+ return `${out}"`;
51
+ }
52
+ /**
53
+ * Re-create the exact JSON bytes the server signed.
54
+ *
55
+ * Mirrors ``json.dumps(value, sort_keys=True, default=str)``: sorted keys,
56
+ * a space after every ``:`` and ``,``, and ASCII-escaped output.
57
+ */
58
+ export function canonicalJson(value) {
59
+ if (value === null || value === undefined)
60
+ return 'null';
61
+ if (typeof value === 'string')
62
+ return encodeString(value);
63
+ if (typeof value === 'boolean')
64
+ return value ? 'true' : 'false';
65
+ if (typeof value === 'number') {
66
+ // Non-finite numbers cannot appear in a JSON.parse result; treat them as
67
+ // null so a hand-constructed value can never throw here.
68
+ return Number.isFinite(value) ? String(value) : 'null';
69
+ }
70
+ if (Array.isArray(value)) {
71
+ return `[${value.map((item) => canonicalJson(item)).join(', ')}]`;
72
+ }
73
+ if (typeof value === 'object') {
74
+ const entries = Object.entries(value)
75
+ // Python sorts by code point; JS's default sort compares UTF-16 code
76
+ // units, which agrees for every key in our payloads (ASCII field names).
77
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
78
+ .map(([key, item]) => `${encodeString(key)}: ${canonicalJson(item)}`);
79
+ return `{${entries.join(', ')}}`;
80
+ }
81
+ // Anything else (function/symbol) can't come out of JSON.parse.
82
+ return 'null';
83
+ }
84
+ /**
85
+ * Constant-time check that ``signature`` is the server's HMAC-SHA256 over
86
+ * ``payload``. Returns ``false`` for an empty signature or secret rather than
87
+ * throwing — the caller decides what an unverifiable frame means.
88
+ */
89
+ export function verifySignature(secret, payload, signature) {
90
+ if (!secret || !signature)
91
+ return false;
92
+ const expected = createHmac('sha256', secret).update(canonicalJson(payload)).digest('hex');
93
+ // Strip the ``sha256=`` prefix the HTTP header carries, in case a future
94
+ // server sends the header form over the socket too.
95
+ const received = signature.startsWith('sha256=') ? signature.slice('sha256='.length) : signature;
96
+ if (received.length !== expected.length)
97
+ return false;
98
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
99
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Webhook forwarding logic — pure functions, no CLI / websocket dependencies.
3
+ *
4
+ * Separated from the `listen` command so tests can exercise the forward-to
5
+ * behaviour against a mock websocket and a mock `fetch`.
6
+ */
7
+ export interface WebhookEvent {
8
+ type: 'event';
9
+ event: string;
10
+ delivery_id: string;
11
+ signature: string;
12
+ payload: Record<string, unknown>;
13
+ }
14
+ export interface ForwardResult {
15
+ delivery_id: string;
16
+ status: number;
17
+ ok: boolean;
18
+ error?: string;
19
+ duration_ms: number;
20
+ }
21
+ export interface ForwardOptions {
22
+ /** Local URL to POST the payload to. */
23
+ forwardTo: string;
24
+ /** HTTP fetch implementation — defaults to the global `fetch`. Injectable for tests. */
25
+ fetchImpl?: typeof fetch;
26
+ /** Request timeout in milliseconds. Defaults to 10 seconds. */
27
+ timeoutMs?: number;
28
+ }
29
+ /**
30
+ * POST a webhook event's payload to the configured local URL, preserving the
31
+ * signature headers so local handlers see the same shape as a production
32
+ * webhook delivery.
33
+ */
34
+ export declare function forwardEvent(event: WebhookEvent, options: ForwardOptions): Promise<ForwardResult>;
35
+ /**
36
+ * Type guard — validates a raw JSON-decoded websocket frame as a
37
+ * :class:`WebhookEvent`. Anything that fails the guard is ignored by the CLI.
38
+ */
39
+ export declare function isWebhookEvent(frame: unknown): frame is WebhookEvent;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Webhook forwarding logic — pure functions, no CLI / websocket dependencies.
3
+ *
4
+ * Separated from the `listen` command so tests can exercise the forward-to
5
+ * behaviour against a mock websocket and a mock `fetch`.
6
+ */
7
+ import { USER_AGENT } from './version.js';
8
+ /**
9
+ * POST a webhook event's payload to the configured local URL, preserving the
10
+ * signature headers so local handlers see the same shape as a production
11
+ * webhook delivery.
12
+ */
13
+ export async function forwardEvent(event, options) {
14
+ const fetchImpl = options.fetchImpl ?? fetch;
15
+ const timeoutMs = options.timeoutMs ?? 10_000;
16
+ const started = Date.now();
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+ try {
20
+ const response = await fetchImpl(options.forwardTo, {
21
+ method: 'POST',
22
+ headers: {
23
+ 'Content-Type': 'application/json',
24
+ 'X-ProhostAI-Event': event.event,
25
+ 'X-ProhostAI-Delivery': event.delivery_id,
26
+ 'X-ProhostAI-Signature': event.signature,
27
+ 'User-Agent': USER_AGENT,
28
+ },
29
+ body: JSON.stringify(event.payload),
30
+ signal: controller.signal,
31
+ });
32
+ const ok = response.status >= 200 && response.status < 300;
33
+ return {
34
+ delivery_id: event.delivery_id,
35
+ status: response.status,
36
+ ok,
37
+ duration_ms: Date.now() - started,
38
+ };
39
+ }
40
+ catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ return {
43
+ delivery_id: event.delivery_id,
44
+ status: 0,
45
+ ok: false,
46
+ error: message,
47
+ duration_ms: Date.now() - started,
48
+ };
49
+ }
50
+ finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+ /**
55
+ * Type guard — validates a raw JSON-decoded websocket frame as a
56
+ * :class:`WebhookEvent`. Anything that fails the guard is ignored by the CLI.
57
+ */
58
+ export function isWebhookEvent(frame) {
59
+ if (!frame || typeof frame !== 'object')
60
+ return false;
61
+ const f = frame;
62
+ return (f.type === 'event' &&
63
+ typeof f.event === 'string' &&
64
+ typeof f.delivery_id === 'string' &&
65
+ typeof f.signature === 'string' &&
66
+ typeof f.payload === 'object' &&
67
+ f.payload !== null);
68
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@prohost/cli` — entrypoint.
4
+ *
5
+ * Two commands:
6
+ *
7
+ * npx @prohost/cli listen --forward-to http://localhost:3000/webhook
8
+ * npx @prohost/cli agent pair --code <pairing-code>
9
+ * npx @prohost/cli agent run --exec 'claude -p'
10
+ *
11
+ * Parses arguments with a zero-dep, zero-magic flag parser so the published
12
+ * binary stays small and starts fast.
13
+ */
14
+ interface ParsedArgs {
15
+ command: string | undefined;
16
+ /** Second positional, e.g. the ``pair`` in ``prohost agent pair``. */
17
+ subcommand: string | undefined;
18
+ flags: Record<string, string | boolean>;
19
+ }
20
+ export declare function parseArgs(argv: readonly string[]): ParsedArgs;
21
+ declare function main(argv: readonly string[]): Promise<number>;
22
+ export { main };
23
+ /**
24
+ * True when this module is the process entrypoint rather than an import.
25
+ *
26
+ * ⚠️ Do NOT "simplify" this back to comparing `import.meta.url` against a
27
+ * hand-built `file://` + `process.argv[1]`. That looks equivalent and is not; it is
28
+ * the exact bug this function exists to fix, and it fails **silently** — the
29
+ * CLI exits 0 having printed nothing, so there is no error to search for.
30
+ * Two independent reasons the string form is wrong:
31
+ *
32
+ * 1. **Symlinks.** `npm i -g` (and `npx`) install a `bin` as a symlink, so
33
+ * `process.argv[1]` is `<prefix>/bin/prohost` while `import.meta.url` is the
34
+ * realpath inside `<prefix>/lib/node_modules/@prohost/cli/dist/`. Those never
35
+ * match, so `main()` never runs for any user who installed the package —
36
+ * i.e. every user. Resolving both sides through `realpathSync` is what makes
37
+ * the symlinked and direct invocations agree.
38
+ *
39
+ * 2. **URL encoding.** A file URL percent-encodes characters that are legal in
40
+ * a path, so concatenating `file://` onto a raw path diverges whenever the
41
+ * path contains a space (`My Documents` → `My%20Documents`), non-ASCII, or a
42
+ * Windows drive letter (`C:\…` → `file:///C:/…`). `fileURLToPath` decodes
43
+ * properly and returns a real filesystem path, which is the only thing safe
44
+ * to compare against another filesystem path.
45
+ *
46
+ * `src/index.test.ts` guards both: the resolver cases directly, and — the
47
+ * load-bearing one — by spawning the built `dist/index.js` through a real
48
+ * symlink and asserting on its **stdout**, because a no-op also exits 0 and
49
+ * exit status alone cannot tell working from broken.
50
+ */
51
+ export declare function isEntrypointModule(moduleUrl: string, argv1: string | undefined, resolve?: (p: string) => string): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@prohost/cli` — entrypoint.
4
+ *
5
+ * Two commands:
6
+ *
7
+ * npx @prohost/cli listen --forward-to http://localhost:3000/webhook
8
+ * npx @prohost/cli agent pair --code <pairing-code>
9
+ * npx @prohost/cli agent run --exec 'claude -p'
10
+ *
11
+ * Parses arguments with a zero-dep, zero-magic flag parser so the published
12
+ * binary stays small and starts fast.
13
+ */
14
+ import { realpathSync } from 'node:fs';
15
+ import process from 'node:process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { runAgent } from './agent/command.js';
18
+ import { DEFAULT_WS_URL, runListen } from './listen.js';
19
+ import { CLI_VERSION } from './version.js';
20
+ function printHelp() {
21
+ const lines = [
22
+ 'prohost — ProhostAI CLI',
23
+ '',
24
+ 'Usage:',
25
+ ' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
26
+ ' prohost agent pair --code <pairing-code> [--webhook-url <https-url>] [--base-url <url>]',
27
+ " prohost agent run --exec '<command>' [--timeout <seconds>] [--dry-run] [--cwd <dir>]",
28
+ '',
29
+ 'listen flags:',
30
+ ' --forward-to Local URL to POST each webhook payload to (required)',
31
+ ' --api-key ProhostAI API key. Defaults to $PROHOST_API_KEY.',
32
+ ' --url Websocket endpoint. Defaults to wss://api.prohost.ai/ws/webhooks.',
33
+ ' --machine Machine name displayed in the UI. Defaults to the OS hostname.',
34
+ ' --subscription-id Stamp connected_from on a specific subscription row.',
35
+ '',
36
+ 'agent pair flags:',
37
+ ' --code One-time pairing code from the hire modal. Defaults to $PROHOST_PAIRING_CODE.',
38
+ ' --webhook-url Also push events to this HTTPS endpoint. Omit it: stream-only is the normal setup.',
39
+ ' --base-url Public API base. Defaults to https://connect.prohost.ai.',
40
+ '',
41
+ 'agent run flags:',
42
+ ' --exec Command to run for each mention; the prompt arrives on stdin (required)',
43
+ ' --timeout Seconds one run may take before it is killed. Defaults to 300.',
44
+ ' --dry-run Print the reply that would be posted; write nothing.',
45
+ ' --cwd Working directory for the agent command.',
46
+ ' --machine Machine name displayed in the UI. Defaults to the OS hostname.',
47
+ ' --url Websocket endpoint. Defaults to the paired value.',
48
+ ' --allow-unverified Run events whose signature could not be verified (not recommended).',
49
+ '',
50
+ 'Global flags:',
51
+ ' --help, -h Show this help and exit.',
52
+ ' --version, -v Print the CLI version and exit.',
53
+ '',
54
+ 'Environment:',
55
+ ' PROHOST_API_KEY Used by `listen` when --api-key is not provided.',
56
+ ' PROHOST_WS_URL Used when --url is not provided.',
57
+ ' PROHOST_PAIRING_CODE Used by `agent pair` when --code is not provided.',
58
+ ' PROHOST_HOME Credential directory. Defaults to ~/.prohost.',
59
+ ];
60
+ process.stdout.write(`${lines.join('\n')}\n`);
61
+ }
62
+ export function parseArgs(argv) {
63
+ const flags = {};
64
+ let command;
65
+ let subcommand;
66
+ let i = 0;
67
+ while (i < argv.length) {
68
+ const token = argv[i];
69
+ if (!token.startsWith('-')) {
70
+ if (!command)
71
+ command = token;
72
+ else if (!subcommand)
73
+ subcommand = token;
74
+ i += 1;
75
+ continue;
76
+ }
77
+ const key = token.replace(/^--?/, '');
78
+ const next = argv[i + 1];
79
+ if (key === 'help' || key === 'h') {
80
+ flags.help = true;
81
+ i += 1;
82
+ continue;
83
+ }
84
+ if (key === 'version' || key === 'v') {
85
+ flags.version = true;
86
+ i += 1;
87
+ continue;
88
+ }
89
+ if (next === undefined || next.startsWith('-')) {
90
+ flags[key] = true;
91
+ i += 1;
92
+ }
93
+ else {
94
+ flags[key] = next;
95
+ i += 2;
96
+ }
97
+ }
98
+ return { command, subcommand, flags };
99
+ }
100
+ async function main(argv) {
101
+ const { command, subcommand, flags } = parseArgs(argv);
102
+ // Checked before the help/no-command branch: `prohost --version` carries no
103
+ // command, which would otherwise print help and exit 1.
104
+ if (flags.version) {
105
+ process.stdout.write(`${CLI_VERSION}\n`);
106
+ return 0;
107
+ }
108
+ // An explicit `--help` is a successful request for help, so it exits 0 — the
109
+ // README opens with `npx @prohost/cli --help`, and a non-zero exit there
110
+ // fails any script or CI step that runs it. Bare `prohost` with no arguments
111
+ // is a usage error and still exits 1.
112
+ if (flags.help) {
113
+ printHelp();
114
+ return 0;
115
+ }
116
+ if (!command) {
117
+ printHelp();
118
+ return 1;
119
+ }
120
+ if (command === 'agent') {
121
+ return runAgent(subcommand, flags, printHelp);
122
+ }
123
+ if (command !== 'listen') {
124
+ process.stderr.write(`Unknown command: ${command}\n\n`);
125
+ printHelp();
126
+ return 1;
127
+ }
128
+ const forwardTo = typeof flags['forward-to'] === 'string' ? flags['forward-to'] : undefined;
129
+ if (!forwardTo) {
130
+ process.stderr.write('Missing required flag: --forward-to\n');
131
+ return 1;
132
+ }
133
+ const apiKey = (typeof flags['api-key'] === 'string' ? flags['api-key'] : undefined) ??
134
+ process.env.PROHOST_API_KEY;
135
+ if (!apiKey) {
136
+ process.stderr.write('Missing API key. Pass --api-key or set $PROHOST_API_KEY.\n');
137
+ return 1;
138
+ }
139
+ const url = (typeof flags['url'] === 'string' ? flags['url'] : undefined) ??
140
+ process.env.PROHOST_WS_URL ??
141
+ DEFAULT_WS_URL;
142
+ const machine = typeof flags['machine'] === 'string' ? flags['machine'] : undefined;
143
+ const subscriptionId = typeof flags['subscription-id'] === 'string' ? flags['subscription-id'] : undefined;
144
+ const controller = new AbortController();
145
+ const onSig = () => controller.abort();
146
+ process.on('SIGINT', onSig);
147
+ process.on('SIGTERM', onSig);
148
+ try {
149
+ await runListen({
150
+ forwardTo,
151
+ apiKey,
152
+ url,
153
+ machine,
154
+ subscriptionId,
155
+ signal: controller.signal,
156
+ });
157
+ process.stdout.write('\n');
158
+ return 0;
159
+ }
160
+ finally {
161
+ process.off('SIGINT', onSig);
162
+ process.off('SIGTERM', onSig);
163
+ }
164
+ }
165
+ // Export the entrypoint so tests can import `parseArgs` without running main.
166
+ export { main };
167
+ /**
168
+ * True when this module is the process entrypoint rather than an import.
169
+ *
170
+ * ⚠️ Do NOT "simplify" this back to comparing `import.meta.url` against a
171
+ * hand-built `file://` + `process.argv[1]`. That looks equivalent and is not; it is
172
+ * the exact bug this function exists to fix, and it fails **silently** — the
173
+ * CLI exits 0 having printed nothing, so there is no error to search for.
174
+ * Two independent reasons the string form is wrong:
175
+ *
176
+ * 1. **Symlinks.** `npm i -g` (and `npx`) install a `bin` as a symlink, so
177
+ * `process.argv[1]` is `<prefix>/bin/prohost` while `import.meta.url` is the
178
+ * realpath inside `<prefix>/lib/node_modules/@prohost/cli/dist/`. Those never
179
+ * match, so `main()` never runs for any user who installed the package —
180
+ * i.e. every user. Resolving both sides through `realpathSync` is what makes
181
+ * the symlinked and direct invocations agree.
182
+ *
183
+ * 2. **URL encoding.** A file URL percent-encodes characters that are legal in
184
+ * a path, so concatenating `file://` onto a raw path diverges whenever the
185
+ * path contains a space (`My Documents` → `My%20Documents`), non-ASCII, or a
186
+ * Windows drive letter (`C:\…` → `file:///C:/…`). `fileURLToPath` decodes
187
+ * properly and returns a real filesystem path, which is the only thing safe
188
+ * to compare against another filesystem path.
189
+ *
190
+ * `src/index.test.ts` guards both: the resolver cases directly, and — the
191
+ * load-bearing one — by spawning the built `dist/index.js` through a real
192
+ * symlink and asserting on its **stdout**, because a no-op also exits 0 and
193
+ * exit status alone cannot tell working from broken.
194
+ */
195
+ export function isEntrypointModule(moduleUrl, argv1, resolve = realpathSync) {
196
+ if (argv1 === undefined)
197
+ return false;
198
+ try {
199
+ return resolve(argv1) === resolve(fileURLToPath(moduleUrl));
200
+ }
201
+ catch {
202
+ // A deleted or unreadable path is not the entrypoint; never throw out of
203
+ // module top-level, which would take the whole process down on import.
204
+ return false;
205
+ }
206
+ }
207
+ const isEntrypoint = isEntrypointModule(import.meta.url, process.argv[1]) ||
208
+ process.env.PROHOST_CLI_RUN === '1';
209
+ if (isEntrypoint) {
210
+ main(process.argv.slice(2)).then((code) => {
211
+ process.exit(code);
212
+ }, (err) => {
213
+ process.stderr.write(`prohost: ${err instanceof Error ? err.message : String(err)}\n`);
214
+ process.exit(1);
215
+ });
216
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `prohost listen --forward-to <url>` — connects to the authenticated
3
+ * websocket, forwards events to a local URL, and displays a live counter.
4
+ */
5
+ import WebSocket from 'ws';
6
+ export interface ListenOptions {
7
+ /** Local URL that each event is POSTed to. */
8
+ forwardTo: string;
9
+ /** ProhostAI API key (``ph_live_*``). Also accepted via ``PROHOST_API_KEY``. */
10
+ apiKey: string;
11
+ /** Websocket endpoint. Defaults to production. */
12
+ url?: string;
13
+ /** Machine name displayed in the UI. Defaults to ``os.hostname()``. */
14
+ machine?: string;
15
+ /** Stamp a specific webhook subscription with this connection. */
16
+ subscriptionId?: string;
17
+ /**
18
+ * Called with a one-line status string every time the counter changes.
19
+ * Defaults to stdout. Tests override this to capture output.
20
+ */
21
+ onStatus?: (line: string) => void;
22
+ /**
23
+ * Websocket constructor. Defaults to ``ws.WebSocket``. Tests pass a mock
24
+ * constructor that exposes a fake server.
25
+ */
26
+ webSocketCtor?: typeof WebSocket;
27
+ /** Injectable ``fetch`` for forwarding — defaults to the global. */
28
+ fetchImpl?: typeof fetch;
29
+ /**
30
+ * Stop the listener when called. Resolves once the socket has been closed
31
+ * and the final status has been emitted.
32
+ */
33
+ signal?: AbortSignal;
34
+ }
35
+ export interface ListenMetrics {
36
+ connected: boolean;
37
+ eventsReceived: number;
38
+ eventsForwarded: number;
39
+ forwardErrors: number;
40
+ }
41
+ export declare const DEFAULT_WS_URL = "wss://api.prohost.ai/ws/webhooks";
42
+ /**
43
+ * Run the listener until the abort signal fires or the socket closes.
44
+ *
45
+ * Exposed as a standalone async function (as opposed to the CLI ``main``)
46
+ * so tests can drive it with a fake websocket.
47
+ */
48
+ export declare function runListen(options: ListenOptions): Promise<ListenMetrics>;
package/dist/listen.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `prohost listen --forward-to <url>` — connects to the authenticated
3
+ * websocket, forwards events to a local URL, and displays a live counter.
4
+ */
5
+ import os from 'node:os';
6
+ import WebSocket from 'ws';
7
+ import { forwardEvent, isWebhookEvent } from './forward.js';
8
+ export const DEFAULT_WS_URL = 'wss://api.prohost.ai/ws/webhooks';
9
+ /**
10
+ * Run the listener until the abort signal fires or the socket closes.
11
+ *
12
+ * Exposed as a standalone async function (as opposed to the CLI ``main``)
13
+ * so tests can drive it with a fake websocket.
14
+ */
15
+ export async function runListen(options) {
16
+ const url = options.url ?? DEFAULT_WS_URL;
17
+ const machine = options.machine ?? os.hostname();
18
+ const Ctor = options.webSocketCtor ?? WebSocket;
19
+ const onStatus = options.onStatus ?? ((line) => process.stdout.write(`${line}\r`));
20
+ const metrics = {
21
+ connected: false,
22
+ eventsReceived: 0,
23
+ eventsForwarded: 0,
24
+ forwardErrors: 0,
25
+ };
26
+ const ws = new Ctor(url);
27
+ return new Promise((resolve) => {
28
+ const status = () => {
29
+ const dot = metrics.connected ? '\u25CF' : '\u25CB';
30
+ onStatus(`${dot} prohost listen \u2192 ${options.forwardTo} | Events: ${metrics.eventsReceived} | Forwarded: ${metrics.eventsForwarded} | Errors: ${metrics.forwardErrors}`);
31
+ };
32
+ const closeWith = () => {
33
+ try {
34
+ ws.close();
35
+ }
36
+ catch {
37
+ /* ignore */
38
+ }
39
+ };
40
+ if (options.signal) {
41
+ if (options.signal.aborted)
42
+ closeWith();
43
+ else
44
+ options.signal.addEventListener('abort', closeWith, { once: true });
45
+ }
46
+ ws.on('open', () => {
47
+ const authFrame = {
48
+ type: 'auth',
49
+ api_key: options.apiKey,
50
+ machine,
51
+ ...(options.subscriptionId ? { subscription_id: options.subscriptionId } : {}),
52
+ };
53
+ ws.send(JSON.stringify(authFrame));
54
+ });
55
+ ws.on('message', async (data) => {
56
+ let frame;
57
+ try {
58
+ frame = JSON.parse(data.toString());
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (!frame || typeof frame !== 'object')
64
+ return;
65
+ const f = frame;
66
+ if (f.type === 'auth_ok') {
67
+ metrics.connected = true;
68
+ status();
69
+ return;
70
+ }
71
+ if (f.type === 'auth_error') {
72
+ onStatus(`\n\u2717 auth_error: ${String(f.reason ?? 'unknown')}\n`);
73
+ closeWith();
74
+ return;
75
+ }
76
+ if (f.type === 'ping') {
77
+ ws.send(JSON.stringify({ type: 'pong' }));
78
+ return;
79
+ }
80
+ if (isWebhookEvent(frame)) {
81
+ metrics.eventsReceived += 1;
82
+ status();
83
+ const result = await forwardEvent(frame, {
84
+ forwardTo: options.forwardTo,
85
+ fetchImpl: options.fetchImpl,
86
+ });
87
+ if (result.ok)
88
+ metrics.eventsForwarded += 1;
89
+ else
90
+ metrics.forwardErrors += 1;
91
+ status();
92
+ try {
93
+ ws.send(JSON.stringify({
94
+ type: 'ack',
95
+ delivery_id: result.delivery_id,
96
+ status: result.status,
97
+ }));
98
+ }
99
+ catch {
100
+ /* best-effort */
101
+ }
102
+ }
103
+ });
104
+ ws.on('close', () => {
105
+ metrics.connected = false;
106
+ status();
107
+ resolve(metrics);
108
+ });
109
+ ws.on('error', () => {
110
+ /* handled via close */
111
+ });
112
+ });
113
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The published version, in one place.
3
+ *
4
+ * Kept as a literal rather than read from `package.json` at runtime: `rootDir`
5
+ * is `src/`, so importing `../package.json` would drag it into `dist/` and
6
+ * change the emitted layout. `version.test.ts` asserts this constant matches
7
+ * `package.json`, so a release bump that forgets this file fails the suite
8
+ * instead of shipping a `User-Agent` that lies about which build is calling.
9
+ */
10
+ export declare const CLI_VERSION = "0.1.0";
11
+ /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
+ export declare const USER_AGENT = "prohost-cli/0.1.0";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The published version, in one place.
3
+ *
4
+ * Kept as a literal rather than read from `package.json` at runtime: `rootDir`
5
+ * is `src/`, so importing `../package.json` would drag it into `dist/` and
6
+ * change the emitted layout. `version.test.ts` asserts this constant matches
7
+ * `package.json`, so a release bump that forgets this file fails the suite
8
+ * instead of shipping a `User-Agent` that lies about which build is calling.
9
+ */
10
+ export const CLI_VERSION = '0.1.0';
11
+ /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
+ export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;