@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 +13 -0
- package/README.md +9 -1
- package/dist/commands.d.ts +1 -1
- package/dist/commands.js +26 -3
- package/dist/listen.d.ts +24 -0
- package/dist/listen.js +86 -0
- package/package.json +3 -2
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
|
-
##
|
|
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
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/listen.d.ts
ADDED
|
@@ -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.
|
|
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"
|