@hermeticacomms/cli 0.1.0 → 0.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ Changes to `@hermeticacomms/cli`, newest first. Its version follows the Hermeticor API version (`info.version` of the OpenAPI document); the product changelog is at https://hermeticor.com/changelog.
4
+
5
+ ## 0.4.0 - 2026-11-13
6
+
7
+ - `webhooks listen --forward-to <url> [--topics a,b]`: forwards your workspace's webhook events to a local URL, signed like real deliveries.
8
+ - `login --paste` no longer echoes the key as you type it.
9
+ - The package ships this changelog.
10
+
11
+ ## 0.1.0 - 2026-11-11
12
+
13
+ - First release: `login` (device code approved in the browser, or `--key` / `--paste`), `whoami`, `status`, `scopes request`, `send --sandbox`, `plan` / `apply` for config as code (YAML or JSON), resource commands (`profiles`, `campaigns`, `events tail`, `export`) and `openapi` for any API operation.
package/README.md CHANGED
@@ -37,7 +37,15 @@ Missing a scope? `hermetica scopes request connections:write --reason "connect S
37
37
 
38
38
  Topics match on `channel` + `key`, fields on `key`, lists on `name`, webhook endpoints on `url`. Only the properties written in the file are compared and changed. Nothing is deleted: `apply --prune` archives the topics, fields and lists (and disables the webhook endpoints) that a section of the file leaves out; sections not in the file are left alone. A webhook `secret` you choose is used on create, replaces the current one when it differs, and is never shown back (the plan prints `secret` only), so verify deliveries with the value from your own secret store. `senders` (`[{ "connection": "main", "fromEmail": "hello@example.com" }]`) declares From addresses per connection; the plan shows whether each is verified.
39
39
 
40
- ## Everything else
40
+ ## Test a webhook receiver locally
41
+
42
+ ```sh
43
+ hermetica webhooks listen --forward-to http://localhost:3000/api/email-events/hermetica
44
+ hermetica webhooks listen --forward-to http://localhost:3000/hooks --topics message.status,suppression.added
45
+ ```
46
+
47
+ Streams the workspace's new events and POSTs each one to `--forward-to` with the body and headers of a real delivery (`Hermetica-Signature`, `Hermetica-Event-Id`, `Hermetica-Topic`), signed with a secret printed when the session starts: set it as your receiver's webhook secret while testing. One line per event shows your receiver's status code. No retries. Ctrl-C ends the session. Needs `webhooks:write` (included in the default login scopes through `integrations:write`).
48
+
41
49
 
42
50
  ```sh
43
51
  hermetica profiles list email=ada@example.com
@@ -17,5 +17,5 @@ export declare function newEvents(client: Client, lastId: string | null, query:
17
17
  }[]>;
18
18
  /** Runs one command line. Throws on bad input or an API error; main.ts prints it and sets the exit code. */
19
19
  export declare function run(argv: string[], io: Io): Promise<void>;
20
- /** Reads the key from the terminal so it never lands in shell history. */
20
+ /** Reads the key from the terminal so it never lands in shell history; the typed characters are not echoed. */
21
21
  export declare function askKey(): Promise<string>;
package/dist/commands.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { createInterface } from 'node:readline/promises';
3
+ import { Writable } from 'node:stream';
3
4
  import { pairs, readData } from './args.js';
4
5
  import { makeClient } from './client.js';
5
6
  import { loadConfig } from './config.js';
6
7
  import { login, logout, requestScopes, status, whoami } from './account.js';
7
8
  import { planOrApply, sendSandbox } from './declare.js';
8
9
  import { buildCall, fetchSpec, findOperation, operations } from './openapi.js';
10
+ import { listen } from './listen.js';
9
11
  export const RESOURCES = {
10
12
  profiles: '/v1/profiles', lists: '/v1/lists', campaigns: '/v1/campaigns', sequences: '/v1/outbound/sequences', templates: '/v1/templates',
11
13
  };
@@ -32,6 +34,7 @@ export const HELP = `hermetica <command>
32
34
  send-test campaign <id> --to a@x.com[,b@y.com] or --data '<json>' (templates need seedListId and connectionId)
33
35
  send-test template <id> --data '<json>'
34
36
  events tail [name=<event>] [--interval 5] print new events as JSON lines until Ctrl-C
37
+ webhooks listen --forward-to <url> [--topics a,b] forward live webhook events to a local receiver, signed like real deliveries
35
38
  export <resource> [key=value ...] every page as JSON lines
36
39
  openapi list [search] operations of the server's OpenAPI document
37
40
  openapi <operationId> [key=value ...] [--data ...] call any operation: path {params} first, the rest as query
@@ -69,6 +72,7 @@ export async function run(argv, io) {
69
72
  key: { type: 'string' }, url: { type: 'string' }, data: { type: 'string', short: 'd' }, to: { type: 'string' }, interval: { type: 'string' }, help: { type: 'boolean', short: 'h' },
70
73
  profile: { type: 'string' }, scopes: { type: 'string' }, paste: { type: 'boolean' }, file: { type: 'string', short: 'f' }, prune: { type: 'boolean' },
71
74
  sandbox: { type: 'boolean' }, outcome: { type: 'string' }, from: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string' }, reason: { type: 'string' },
75
+ 'forward-to': { type: 'string' }, topics: { type: 'string' },
72
76
  },
73
77
  });
74
78
  if (!cmd || f.help || cmd === 'help')
@@ -133,6 +137,19 @@ export async function run(argv, io) {
133
137
  await io.sleep(every);
134
138
  }
135
139
  }
140
+ if (cmd === 'webhooks' && a === 'listen') {
141
+ if (!f['forward-to'])
142
+ throw new Error('Usage: hermetica webhooks listen --forward-to http://localhost:3000/webhooks [--topics profile.*,message.event]');
143
+ const stop = new AbortController();
144
+ const onInt = () => stop.abort(); // first Ctrl-C stops cleanly (the session is deleted); a second one exits at once
145
+ process.once('SIGINT', onInt);
146
+ try {
147
+ return await listen(io, client, cfg, { forwardTo: f['forward-to'], topics: f.topics?.split(',').map((t) => t.trim()).filter(Boolean), signal: stop.signal });
148
+ }
149
+ finally {
150
+ process.off('SIGINT', onInt);
151
+ }
152
+ }
136
153
  if (cmd === 'export' && a && a in LISTABLE) {
137
154
  for await (const items of pages(client, LISTABLE[a], pairs([b, ...more].filter((x) => !!x))))
138
155
  for (const x of items)
@@ -154,13 +171,19 @@ export async function run(argv, io) {
154
171
  }
155
172
  throw new Error(`Unknown command "${[cmd, a].filter(Boolean).join(' ')}".\n\n${HELP}`);
156
173
  }
157
- /** Reads the key from the terminal so it never lands in shell history. */
174
+ /** Reads the key from the terminal so it never lands in shell history; the typed characters are not echoed. */
158
175
  export async function askKey() {
159
- const rl = createInterface({ input: process.stdin, output: process.stderr });
176
+ let muted = false;
177
+ const output = new Writable({ write(chunk, _enc, done) { if (!muted)
178
+ process.stderr.write(chunk); done(); } });
179
+ const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY) });
160
180
  try {
161
- return await rl.question('API key (hm_sk_...): ');
181
+ const answer = rl.question('API key (hm_sk_...): '); // the prompt is written before this returns
182
+ muted = true;
183
+ return await answer;
162
184
  }
163
185
  finally {
164
186
  rl.close();
187
+ process.stderr.write('\n');
165
188
  }
166
189
  }
@@ -0,0 +1,24 @@
1
+ import { type Client } from './client.ts';
2
+ import type { CliConfig } from './config.ts';
3
+ import type { Io } from './commands.ts';
4
+ /** `Hermetica-Signature` as real deliveries send it: `t=<unix>,v1=<hex hmac-sha256 of "t.body">`. */
5
+ export declare const signBody: (secret: string, body: string, nowSec: number) => string;
6
+ /** POSTs one event body to the local receiver with the headers of a real delivery. Never throws: returns the status or the error text. */
7
+ export declare function forwardEvent(fetchFn: typeof fetch, url: string, secret: string, body: string, nowSec: number): Promise<{
8
+ status: number | null;
9
+ error?: string;
10
+ }>;
11
+ /** `event:`/`data:` blocks of a server-sent event stream. */
12
+ export declare function sseEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<{
13
+ event: string;
14
+ data: string;
15
+ }>;
16
+ /**
17
+ * `webhooks listen`: opens a listen session, streams its events and forwards each to `forwardTo`, signed with the session secret.
18
+ * Reconnects when the server closes the stream; ends when the session is gone (404) or on Ctrl-C (`signal`), deleting the session.
19
+ */
20
+ export declare function listen(io: Io, client: Client, cfg: CliConfig, o: {
21
+ forwardTo: string;
22
+ topics?: string[] | undefined;
23
+ signal?: AbortSignal | undefined;
24
+ }): Promise<void>;
package/dist/listen.js ADDED
@@ -0,0 +1,86 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { ApiError } from './client.js';
3
+ /** `Hermetica-Signature` as real deliveries send it: `t=<unix>,v1=<hex hmac-sha256 of "t.body">`. */
4
+ export const signBody = (secret, body, nowSec) => `t=${nowSec},v1=${createHmac('sha256', secret).update(`${nowSec}.${body}`).digest('hex')}`;
5
+ /** POSTs one event body to the local receiver with the headers of a real delivery. Never throws: returns the status or the error text. */
6
+ export async function forwardEvent(fetchFn, url, secret, body, nowSec) {
7
+ const e = JSON.parse(body);
8
+ try {
9
+ const res = await fetchFn(url, {
10
+ method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(10_000), body,
11
+ headers: { 'content-type': 'application/json', 'user-agent': 'Hermetica-Webhooks/1', 'hermetica-signature': signBody(secret, body, nowSec), 'hermetica-event-id': e.id, 'hermetica-topic': e.topic },
12
+ });
13
+ await res.body?.cancel();
14
+ return { status: res.status };
15
+ }
16
+ catch (err) {
17
+ return { status: null, error: err.message };
18
+ }
19
+ }
20
+ /** `event:`/`data:` blocks of a server-sent event stream. */
21
+ export async function* sseEvents(body) {
22
+ let buf = '';
23
+ const dec = new TextDecoder();
24
+ for await (const bytes of body) {
25
+ buf += dec.decode(bytes, { stream: true });
26
+ for (let i = buf.indexOf('\n\n'); i >= 0; i = buf.indexOf('\n\n')) {
27
+ const block = buf.slice(0, i);
28
+ buf = buf.slice(i + 2);
29
+ const event = /^event: (.*)$/m.exec(block)?.[1];
30
+ const data = /^data: (.*)$/m.exec(block)?.[1];
31
+ if (event && data !== undefined)
32
+ yield { event, data };
33
+ }
34
+ }
35
+ }
36
+ /**
37
+ * `webhooks listen`: opens a listen session, streams its events and forwards each to `forwardTo`, signed with the session secret.
38
+ * Reconnects when the server closes the stream; ends when the session is gone (404) or on Ctrl-C (`signal`), deleting the session.
39
+ */
40
+ export async function listen(io, client, cfg, o) {
41
+ if (!/^https?:\/\//.test(o.forwardTo))
42
+ throw new Error('--forward-to must be an http(s) URL, e.g. http://localhost:3000/webhooks');
43
+ const s = (await client.request('POST', '/v1/webhooks/listen', { body: o.topics?.length ? { topics: o.topics } : {} }));
44
+ io.out(`Ready. Forwarding ${s.topics.join(', ')} to ${o.forwardTo}`);
45
+ io.out(`Your webhook signing secret for this session is ${s.secret} (set it in your receiver; Ctrl-C to stop)`);
46
+ try {
47
+ while (!o.signal?.aborted) {
48
+ let res;
49
+ try {
50
+ res = await io.fetch(`${cfg.baseUrl}/v1/webhooks/listen/${s.id}/events`, { headers: { authorization: `Bearer ${cfg.apiKey}`, accept: 'text/event-stream' }, ...(o.signal ? { signal: o.signal } : {}) });
51
+ }
52
+ catch {
53
+ if (o.signal?.aborted)
54
+ break;
55
+ await io.sleep(2000); // network blip: try again
56
+ continue;
57
+ }
58
+ if (res.status === 404) {
59
+ io.out('The listen session ended.');
60
+ return;
61
+ }
62
+ if (!res.ok || !res.body)
63
+ throw new ApiError(res.status, 'http_error', `Could not open the event stream (${res.status})`);
64
+ try {
65
+ for await (const e of sseEvents(res.body)) {
66
+ if (e.event === 'done') {
67
+ io.out('The listen session ended.');
68
+ return;
69
+ }
70
+ if (e.event !== 'webhook')
71
+ continue;
72
+ const r = await forwardEvent(io.fetch, o.forwardTo, s.secret, e.data, Math.floor(Date.now() / 1000));
73
+ const { id, topic } = JSON.parse(e.data);
74
+ io.out(`${new Date().toISOString()} ${topic} ${id} -> ${r.status ?? `error: ${r.error}`}`);
75
+ }
76
+ }
77
+ catch {
78
+ if (!o.signal?.aborted)
79
+ await io.sleep(1000);
80
+ } // stream cut mid-way: reconnect, the cursor is kept server-side
81
+ }
82
+ }
83
+ finally {
84
+ await client.request('DELETE', `/v1/webhooks/listen/${s.id}`).catch(() => { }); // already gone is fine
85
+ }
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hermeticacomms/cli",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Hermetica command line: device login, config as code, sandbox sends, any API operation",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,8 @@
23
23
  }
24
24
  },
25
25
  "files": [
26
- "dist"
26
+ "dist",
27
+ "CHANGELOG.md"
27
28
  ],
28
29
  "engines": {
29
30
  "node": ">=20"