@hermeticacomms/cli 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hermetica
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,60 @@
1
- # Temporary Holding Version
1
+ # @hermeticacomms/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The `hermetica` command: sign in from the terminal, keep workspace setup in a file, send sandbox test mail and call any API operation. Node 20+.
4
+
5
+ ```sh
6
+ npm i -g @hermeticacomms/cli
7
+ ```
8
+
9
+ ## Quickstart
10
+
11
+ ```sh
12
+ hermetica login # shows a code, opens the browser; approve there
13
+ hermetica whoami # the key's workspace and scopes
14
+ hermetica status # what blocks sending, and the API call that fixes each step
15
+ hermetica send --sandbox --outcome bounce # test mail to bounce@sandbox.hermeticor.test, no provider needed
16
+ hermetica plan -f hermetica.json # what apply would change
17
+ hermetica apply -f hermetica.json # make the workspace match the file (run it twice: the second run changes nothing)
18
+ ```
19
+
20
+ `login` stores the key in `~/.config/hermetica/credentials` (mode 0600), one entry per `--profile` (default `default`; or set `HERMETICA_PROFILE`). The key is never printed. `login --scopes profiles:read,consent:write` asks for specific scopes; `login --key hm_sk_...` or `login --paste` stores a key you already have. `--url` (or `HERMETICA_API_URL`) points at another instance; the default is `https://hermeticor.com`. `HERMETICA_API_KEY` overrides the stored key, for CI.
21
+
22
+ Missing a scope? `hermetica scopes request connections:write --reason "connect SES"` prints a link for a workspace owner and waits for the decision.
23
+
24
+ ## Config as code
25
+
26
+ `plan` / `apply` send the file to `PUT /v1/config`. The file is YAML (`.yaml`/`.yml`) or JSON, same shape (shown as JSON):
27
+
28
+ ```json
29
+ {
30
+ "topics": [{ "channel": "email", "key": "product-news", "name": "Product news" }],
31
+ "fields": [{ "key": "plan", "label": "Plan", "type": "text" }],
32
+ "lists": [{ "name": "Customers", "topicKey": "product-news" }],
33
+ "webhookEndpoints": [{ "url": "https://example.com/hooks/hermetica", "topics": ["profile.*"], "secret": "<32+ random characters>" }],
34
+ "postalAddress": "1 Main St, Springfield"
35
+ }
36
+ ```
37
+
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
+
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
+
49
+
50
+ ```sh
51
+ hermetica profiles list email=ada@example.com
52
+ hermetica campaigns create --data @campaign.json
53
+ hermetica events tail name=signed_up
54
+ hermetica export lists > lists.jsonl
55
+ hermetica openapi list segments # every operation of the server's OpenAPI document
56
+ hermetica openapi getV1SegmentsByIdMembers id=<segmentId> limit=10
57
+ hermetica help
58
+ ```
59
+
60
+ Every write sends a fresh `Idempotency-Key`. Errors print the API's status, code and details and exit 1.
@@ -0,0 +1,27 @@
1
+ import { type Client } from './client.ts';
2
+ import type { Io } from './commands.ts';
3
+ type Granted = {
4
+ key: string;
5
+ prefix: string;
6
+ scopes: string[];
7
+ workspaceId: string;
8
+ };
9
+ /** What the CLI's own commands need (status, sequences/mailboxes/leads/inbox, plan/apply, send); the approver sees and may trim them. */
10
+ export declare const DEFAULT_LOGIN_SCOPES: string[];
11
+ export interface LoginFlags {
12
+ key?: string | undefined;
13
+ url?: string | undefined;
14
+ paste?: boolean | undefined;
15
+ scopes?: string | undefined;
16
+ profile?: string | undefined;
17
+ }
18
+ /** OAuth-style device flow: show the code, open the browser, poll at the server's pace until approved, denied or expired. */
19
+ export declare function deviceLogin(io: Io, baseUrl: string, scopes?: string[]): Promise<Granted>;
20
+ /** Device login by default; `--key` / `--paste` store an existing secret key instead. The key itself is never printed. */
21
+ export declare function login(io: Io, f: LoginFlags): Promise<void>;
22
+ export declare function logout(io: Io, profile?: string): Promise<void>;
23
+ export declare function whoami(io: Io, client: Client): Promise<void>;
24
+ export declare function status(io: Io, client: Client): Promise<void>;
25
+ /** Asks an owner for more scopes on this key, then waits for the decision (Ctrl-C stops waiting; the request stays open). */
26
+ export declare function requestScopes(io: Io, client: Client, scopes: string[], reason: string | undefined, intervalSec?: number): Promise<void>;
27
+ export {};
@@ -0,0 +1,82 @@
1
+ import { ApiError, makeClient } from './client.js';
2
+ import { baseUrlOf, clearConfig, profileOf, saveConfig, savedBaseUrl } from './config.js';
3
+ /** What the CLI's own commands need (status, sequences/mailboxes/leads/inbox, plan/apply, send); the approver sees and may trim them. */
4
+ export const DEFAULT_LOGIN_SCOPES = ['settings:read', 'workflows:read', 'outbound:read', 'leads:read', 'profiles:read', 'profiles:write', 'consent:read', 'consent:write', 'integrations:read', 'integrations:write', 'messages:send'];
5
+ const reasonOf = (e) => e instanceof ApiError && e.status === 400 ? e.details?.reason : undefined;
6
+ /** OAuth-style device flow: show the code, open the browser, poll at the server's pace until approved, denied or expired. */
7
+ export async function deviceLogin(io, baseUrl, scopes) {
8
+ const anon = makeClient({ apiKey: '', baseUrl }, io.fetch);
9
+ const d = (await anon.request('POST', '/v1/auth/device', { body: { scopes: scopes?.length ? scopes : DEFAULT_LOGIN_SCOPES } }));
10
+ io.out(`Open ${d.verificationUrl} and enter the code ${d.userCode}`);
11
+ io.openUrl?.(d.verificationUrlComplete ?? d.verificationUrl);
12
+ let every = Math.max(1, d.interval ?? 5) * 1000;
13
+ for (let waited = 0; waited < d.expiresIn * 1000; waited += every) {
14
+ await io.sleep(every);
15
+ try {
16
+ return (await anon.request('POST', '/v1/auth/device/token', { body: { deviceCode: d.deviceCode } }));
17
+ }
18
+ catch (e) {
19
+ const reason = reasonOf(e);
20
+ if (reason === 'authorization_pending')
21
+ continue;
22
+ if (reason === 'slow_down') {
23
+ every += 5000;
24
+ continue;
25
+ }
26
+ if (reason === 'access_denied')
27
+ throw new Error('The login was denied in the browser.', { cause: e });
28
+ if (reason === 'expired_token')
29
+ break;
30
+ throw e;
31
+ }
32
+ }
33
+ throw new Error('The login code expired. Run "hermetica login" again.');
34
+ }
35
+ /** Device login by default; `--key` / `--paste` store an existing secret key instead. The key itself is never printed. */
36
+ export async function login(io, f) {
37
+ const profile = profileOf(io.env, f.profile);
38
+ const baseUrl = baseUrlOf(io.env, await savedBaseUrl(io.env, profile), f.url);
39
+ if (f.key || f.paste) {
40
+ const apiKey = (f.key ?? (await io.askKey())).trim();
41
+ if (!apiKey.startsWith('hm_sk_'))
42
+ throw new Error('That is not a secret key (hm_sk_...)');
43
+ const ws = (await makeClient({ apiKey, baseUrl }, io.fetch).request('GET', '/v1/workspace'));
44
+ const file = await saveConfig({ apiKey, baseUrl }, io.env, profile);
45
+ return io.out(`Logged in to ${ws.name ?? ws.id ?? 'the workspace'} at ${baseUrl}. Key stored in ${file} (0600), profile ${profile}.`);
46
+ }
47
+ const g = await deviceLogin(io, baseUrl, f.scopes?.split(',').map((s) => s.trim()).filter(Boolean));
48
+ const file = await saveConfig({ apiKey: g.key, baseUrl }, io.env, profile);
49
+ io.out(`Logged in to workspace ${g.workspaceId} with key ${g.prefix}... (scopes: ${g.scopes.join(', ') || 'none'}). Stored in ${file} (0600), profile ${profile}.`);
50
+ }
51
+ export async function logout(io, profile) {
52
+ await clearConfig(io.env, profileOf(io.env, profile));
53
+ io.out(`Logged out (profile ${profileOf(io.env, profile)}).`);
54
+ }
55
+ export async function whoami(io, client) {
56
+ const me = (await client.request('GET', '/v1/me'));
57
+ io.out(`${me.user?.email ?? `${me.actor.type} ${me.actor.id}`}${me.actor.role ? ` (${me.actor.role})` : ''}\nworkspace ${me.actor.workspaceId}\nscopes ${me.actor.scopes.join(' ') || '(none)'}`);
58
+ }
59
+ export async function status(io, client) {
60
+ const r = (await client.request('GET', '/v1/readiness'));
61
+ io.out(Object.entries(r.canSend).map(([k, v]) => `${k}: ${v ? 'yes' : 'no'}`).join(' '));
62
+ for (const c of r.checks.filter((x) => !x.ok)) {
63
+ io.out(`- ${c.key}${c.blocks.length ? ` (blocks ${c.blocks.join(', ')})` : ''}: ${c.fix}`);
64
+ if (c.api)
65
+ io.out(` ${c.api.method} ${c.api.path}${c.requiredScope && c.callerHasScope === false ? ` (needs ${c.requiredScope}: hermetica scopes request ${c.requiredScope} --reason ...)` : ''}`);
66
+ }
67
+ if (r.checks.every((x) => x.ok))
68
+ io.out('Nothing blocks sending.');
69
+ }
70
+ /** Asks an owner for more scopes on this key, then waits for the decision (Ctrl-C stops waiting; the request stays open). */
71
+ export async function requestScopes(io, client, scopes, reason, intervalSec = 5) {
72
+ if (!scopes.length || !reason)
73
+ throw new Error('Usage: hermetica scopes request <scope> [<scope> ...] --reason "why you need them"');
74
+ let r = (await client.request('POST', '/v1/api-keys/requests', { body: { scopes, reason } }));
75
+ if (r.approveUrl)
76
+ io.out(`Ask a workspace owner to approve: ${r.approveUrl}`);
77
+ for (let i = 0; r.status === 'pending' && i < 720; i++) {
78
+ await io.sleep(intervalSec * 1000);
79
+ r = (await client.request('GET', `/v1/api-keys/requests/${encodeURIComponent(r.id)}`));
80
+ }
81
+ io.out(r.status === 'pending' ? `Still pending; check later with: hermetica openapi list api-keys/requests (request ${r.id})` : `Request ${r.status}.`);
82
+ }
package/dist/args.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** `key=value` words into a record; any other word is an error, so a typo never becomes a silent positional. */
2
+ export declare function pairs(words: string[]): Record<string, string>;
3
+ /** `--data` as inline JSON, `@file.json`, or `-` for stdin. */
4
+ export declare function readData(raw: string | undefined): Promise<unknown>;
package/dist/args.js ADDED
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ /** `key=value` words into a record; any other word is an error, so a typo never becomes a silent positional. */
3
+ export function pairs(words) {
4
+ const out = {};
5
+ for (const w of words) {
6
+ const i = w.indexOf('=');
7
+ if (i < 1)
8
+ throw new Error(`Expected key=value, got "${w}"`);
9
+ out[w.slice(0, i)] = w.slice(i + 1);
10
+ }
11
+ return out;
12
+ }
13
+ const readStdin = async () => { let s = ''; for await (const c of process.stdin)
14
+ s += String(c); return s; };
15
+ /** `--data` as inline JSON, `@file.json`, or `-` for stdin. */
16
+ export async function readData(raw) {
17
+ if (raw === undefined)
18
+ return undefined;
19
+ const text = raw === '-' ? await readStdin() : raw.startsWith('@') ? await readFile(raw.slice(1), 'utf8') : raw;
20
+ try {
21
+ return JSON.parse(text);
22
+ }
23
+ catch {
24
+ throw new Error('--data is not valid JSON');
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ import type { CliConfig } from './config.ts';
2
+ export type Query = Record<string, string | number | boolean | undefined>;
3
+ export interface Client {
4
+ request: (method: string, path: string, o?: {
5
+ query?: Query;
6
+ body?: unknown;
7
+ }) => Promise<unknown>;
8
+ }
9
+ export declare class ApiError extends Error {
10
+ readonly status: number;
11
+ readonly code: string;
12
+ readonly details?: unknown;
13
+ constructor(status: number, code: string, message: string, details?: unknown);
14
+ }
15
+ /** Bearer-key client over the REST API (no key: anonymous, for the device login). Writes carry a fresh Idempotency-Key, so a retried command never repeats the action server-side. */
16
+ export declare function makeClient(cfg: CliConfig, fetchFn?: typeof fetch): Client;
package/dist/client.js ADDED
@@ -0,0 +1,35 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class ApiError extends Error {
3
+ status;
4
+ code;
5
+ details;
6
+ constructor(status, code, message, details) { super(message); this.status = status; this.code = code; this.details = details; }
7
+ }
8
+ /** Bearer-key client over the REST API (no key: anonymous, for the device login). Writes carry a fresh Idempotency-Key, so a retried command never repeats the action server-side. */
9
+ export function makeClient(cfg, fetchFn = fetch) {
10
+ return {
11
+ async request(method, path, o = {}) {
12
+ const url = new URL(`${cfg.baseUrl}${path}`); // keeps a base path such as https://host/api
13
+ for (const [k, v] of Object.entries(o.query ?? {}))
14
+ if (v !== undefined)
15
+ url.searchParams.set(k, String(v));
16
+ const write = method !== 'GET' && method !== 'HEAD';
17
+ const res = await fetchFn(url, {
18
+ method,
19
+ headers: { ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}), accept: 'application/json', ...(o.body === undefined ? {} : { 'content-type': 'application/json' }), ...(write ? { 'idempotency-key': randomUUID() } : {}) },
20
+ ...(o.body === undefined ? {} : { body: JSON.stringify(o.body) }),
21
+ });
22
+ const text = await res.text();
23
+ let data = text;
24
+ try {
25
+ data = text ? JSON.parse(text) : null;
26
+ }
27
+ catch { /* plain text body */ }
28
+ if (!res.ok) {
29
+ const e = data?.error;
30
+ throw new ApiError(res.status, e?.code ?? 'http_error', e?.message ?? `${res.status} ${res.statusText}`, e?.details);
31
+ }
32
+ return data;
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,21 @@
1
+ import { type Client } from './client.ts';
2
+ export declare const RESOURCES: Record<string, string>;
3
+ /** list/get only: creating these goes through flows (connect a mailbox, a lead search, a received reply) the CLI does not model. */
4
+ export declare const READ_ONLY: Record<string, string>;
5
+ export declare const HELP: string;
6
+ export interface Io {
7
+ env: NodeJS.ProcessEnv;
8
+ fetch: typeof fetch;
9
+ out: (s: string) => void;
10
+ sleep: (ms: number) => Promise<void>;
11
+ askKey: () => Promise<string>;
12
+ openUrl?: (url: string) => void;
13
+ }
14
+ /** Newest-first event pages until a seen id: UUIDv7 ids sort by arrival, so everything above `lastId` is new. Returned oldest first. */
15
+ export declare function newEvents(client: Client, lastId: string | null, query: Record<string, string>): Promise<{
16
+ id: string;
17
+ }[]>;
18
+ /** Runs one command line. Throws on bad input or an API error; main.ts prints it and sets the exit code. */
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; the typed characters are not echoed. */
21
+ export declare function askKey(): Promise<string>;
@@ -0,0 +1,189 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { Writable } from 'node:stream';
4
+ import { pairs, readData } from './args.js';
5
+ import { makeClient } from './client.js';
6
+ import { loadConfig } from './config.js';
7
+ import { login, logout, requestScopes, status, whoami } from './account.js';
8
+ import { planOrApply, sendSandbox } from './declare.js';
9
+ import { buildCall, fetchSpec, findOperation, operations } from './openapi.js';
10
+ import { listen } from './listen.js';
11
+ export const RESOURCES = {
12
+ profiles: '/v1/profiles', lists: '/v1/lists', campaigns: '/v1/campaigns', sequences: '/v1/outbound/sequences', templates: '/v1/templates',
13
+ };
14
+ /** list/get only: creating these goes through flows (connect a mailbox, a lead search, a received reply) the CLI does not model. */
15
+ export const READ_ONLY = { mailboxes: '/v1/outbound/mailboxes', leads: '/v1/leads', inbox: '/v1/outbound/inbox' };
16
+ const LISTABLE = { ...RESOURCES, ...READ_ONLY };
17
+ export const HELP = `hermetica <command>
18
+
19
+ login [--scopes a:read,b:write] [--url https://...] sign in through the browser (device code); the key is stored, never shown
20
+ login --key hm_sk_... | --paste store an existing secret key instead
21
+ logout forget the stored key of this profile
22
+ whoami who the stored key acts as, and its scopes
23
+ status what blocks sending, with the API call that fixes each step
24
+ plan -f hermetica.yaml [--prune] show what apply would change (config as code, YAML or JSON)
25
+ apply -f hermetica.yaml [--prune] create/update to match the file; --prune archives what it leaves out
26
+ send --sandbox [--outcome bounce|soft-bounce|complaint] test email to <outcome>@sandbox.hermeticor.test, no provider needed
27
+ scopes request <scope> [...] --reason "..." ask an owner for more scopes and wait for the answer
28
+ <resource> list [key=value ...] resources: ${Object.keys(RESOURCES).join(', ')}
29
+ <resource> get <id>
30
+ <resource> create --data '<json>' | @file.json | -
31
+ ${Object.keys(READ_ONLY).join(', ')} list [key=value ...] | get <id>
32
+ enrollments list <sequenceId> [key=value ...] prospects enrolled in a sequence or workflow
33
+ enrollments get <sequenceId> <enrollmentId> the step trail of one enrollment
34
+ send-test campaign <id> --to a@x.com[,b@y.com] or --data '<json>' (templates need seedListId and connectionId)
35
+ send-test template <id> --data '<json>'
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
38
+ export <resource> [key=value ...] every page as JSON lines
39
+ openapi list [search] operations of the server's OpenAPI document
40
+ openapi <operationId> [key=value ...] [--data ...] call any operation: path {params} first, the rest as query
41
+
42
+ --profile <name> (or HERMETICA_PROFILE) picks a stored login; HERMETICA_API_KEY and HERMETICA_API_URL override it.`;
43
+ const json = (x) => JSON.stringify(x, null, 2);
44
+ async function* pages(client, path, query) {
45
+ let cursor;
46
+ do {
47
+ const p = (await client.request('GET', path, { query: { limit: 200, ...query, ...(cursor ? { cursor } : {}) } }));
48
+ yield p.items ?? [];
49
+ cursor = p.nextCursor ?? undefined;
50
+ } while (cursor);
51
+ }
52
+ /** Newest-first event pages until a seen id: UUIDv7 ids sort by arrival, so everything above `lastId` is new. Returned oldest first. */
53
+ export async function newEvents(client, lastId, query) {
54
+ const fresh = [];
55
+ let cursor;
56
+ for (let i = 0; i < 10; i++) {
57
+ const p = (await client.request('GET', '/v1/events', { query: { limit: 100, ...query, ...(cursor ? { cursor } : {}) } }));
58
+ const mine = lastId ? p.items.filter((e) => e.id > lastId) : p.items;
59
+ fresh.push(...mine);
60
+ // The first poll only shows the latest page, like tail.
61
+ if (!lastId || mine.length < p.items.length || !p.nextCursor)
62
+ break;
63
+ cursor = p.nextCursor;
64
+ }
65
+ return fresh.toReversed();
66
+ }
67
+ /** Runs one command line. Throws on bad input or an API error; main.ts prints it and sets the exit code. */
68
+ export async function run(argv, io) {
69
+ const { values: f, positionals: [cmd, ...rest] } = parseArgs({
70
+ args: argv, allowPositionals: true,
71
+ options: {
72
+ key: { type: 'string' }, url: { type: 'string' }, data: { type: 'string', short: 'd' }, to: { type: 'string' }, interval: { type: 'string' }, help: { type: 'boolean', short: 'h' },
73
+ profile: { type: 'string' }, scopes: { type: 'string' }, paste: { type: 'boolean' }, file: { type: 'string', short: 'f' }, prune: { type: 'boolean' },
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' },
76
+ },
77
+ });
78
+ if (!cmd || f.help || cmd === 'help')
79
+ return io.out(HELP);
80
+ if (cmd === 'login')
81
+ return login(io, f);
82
+ if (cmd === 'logout')
83
+ return logout(io, f.profile);
84
+ const cfg = await loadConfig(io.env, f.profile || undefined);
85
+ if (!cfg)
86
+ throw new Error('Not logged in. Run "hermetica login" or set HERMETICA_API_KEY.');
87
+ const client = makeClient(cfg, io.fetch);
88
+ const [a, b, ...more] = rest;
89
+ if (cmd === 'whoami')
90
+ return whoami(io, client);
91
+ if (cmd === 'status')
92
+ return status(io, client);
93
+ if (cmd === 'plan' || cmd === 'apply')
94
+ return planOrApply(io, client, cmd === 'apply', f);
95
+ if (cmd === 'send')
96
+ return sendSandbox(io, client, f);
97
+ if (cmd === 'scopes' && a === 'request')
98
+ return requestScopes(io, client, [b, ...more].filter((x) => !!x), f.reason, Math.max(1, Number(f.interval ?? 5)));
99
+ if (cmd in LISTABLE) {
100
+ const base = LISTABLE[cmd];
101
+ if (a === 'list')
102
+ return io.out(json(await client.request('GET', base, { query: pairs([b, ...more].filter((x) => !!x)) })));
103
+ if (a === 'get' && b)
104
+ return io.out(json(await client.request('GET', `${base}/${encodeURIComponent(b)}`)));
105
+ if (a === 'create' && cmd in RESOURCES)
106
+ return io.out(json(await client.request('POST', base, { body: (await readData(f.data)) ?? {} })));
107
+ throw new Error(`Usage: hermetica ${cmd} list | get <id>${cmd in RESOURCES ? ' | create --data ...' : ''}`);
108
+ }
109
+ if (cmd === 'enrollments') {
110
+ // A sequence is a workflow in sequence mode, so its enrollments live under /v1/workflows/:id.
111
+ const [id, enrollmentId] = [b, more[0]];
112
+ const base = id && `/v1/workflows/${encodeURIComponent(id)}/enrollments`;
113
+ if (a === 'list' && base)
114
+ return io.out(json(await client.request('GET', base, { query: pairs(more.filter((x) => !!x)) })));
115
+ if (a === 'get' && base && enrollmentId)
116
+ return io.out(json(await client.request('GET', `${base}/${encodeURIComponent(enrollmentId)}/steps`)));
117
+ throw new Error('Usage: hermetica enrollments list <sequenceId> [key=value ...] | get <sequenceId> <enrollmentId>');
118
+ }
119
+ if (cmd === 'send-test') {
120
+ if ((a !== 'campaign' && a !== 'template') || !b)
121
+ throw new Error('Usage: hermetica send-test campaign|template <id> --to ... | --data ...');
122
+ const body = (await readData(f.data)) ?? (f.to ? { emails: f.to.split(',').map((s) => s.trim()).filter(Boolean) } : undefined);
123
+ if (!body)
124
+ throw new Error('Give --to or --data');
125
+ return io.out(json(await client.request('POST', `${RESOURCES[`${a}s`]}/${encodeURIComponent(b)}/test-send`, { body })));
126
+ }
127
+ if (cmd === 'events' && a === 'tail') {
128
+ const query = pairs([b, ...more].filter((x) => !!x));
129
+ const every = Math.max(1, Number(f.interval ?? 5)) * 1000;
130
+ let last = null;
131
+ for (;;) {
132
+ for (const e of await newEvents(client, last, query)) {
133
+ io.out(JSON.stringify(e));
134
+ if (!last || e.id > last)
135
+ last = e.id;
136
+ }
137
+ await io.sleep(every);
138
+ }
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
+ }
153
+ if (cmd === 'export' && a && a in LISTABLE) {
154
+ for await (const items of pages(client, LISTABLE[a], pairs([b, ...more].filter((x) => !!x))))
155
+ for (const x of items)
156
+ io.out(JSON.stringify(x));
157
+ return;
158
+ }
159
+ if (cmd === 'openapi' && a) {
160
+ const spec = await fetchSpec(client);
161
+ if (a === 'list') {
162
+ const needle = (b ?? '').toLowerCase();
163
+ for (const o of operations(spec))
164
+ if (!needle || `${o.id} ${o.path} ${o.summary}`.toLowerCase().includes(needle))
165
+ io.out(`${o.id}\t${o.method} ${o.path}${o.summary ? `\t${o.summary}` : ''}`);
166
+ return;
167
+ }
168
+ const call = buildCall(findOperation(spec, a), pairs([b, ...more].filter((x) => !!x)));
169
+ const body = await readData(f.data);
170
+ return io.out(json(await client.request(call.method, call.path, { query: call.query, ...(body === undefined ? {} : { body }) })));
171
+ }
172
+ throw new Error(`Unknown command "${[cmd, a].filter(Boolean).join(' ')}".\n\n${HELP}`);
173
+ }
174
+ /** Reads the key from the terminal so it never lands in shell history; the typed characters are not echoed. */
175
+ export async function askKey() {
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) });
180
+ try {
181
+ const answer = rl.question('API key (hm_sk_...): '); // the prompt is written before this returns
182
+ muted = true;
183
+ return await answer;
184
+ }
185
+ finally {
186
+ rl.close();
187
+ process.stderr.write('\n');
188
+ }
189
+ }
@@ -0,0 +1,15 @@
1
+ export interface CliConfig {
2
+ apiKey: string;
3
+ baseUrl: string;
4
+ }
5
+ export declare const DEFAULT_URL = "https://hermeticor.com";
6
+ /** `$XDG_CONFIG_HOME/hermetica` (default `~/.config/hermetica`). */
7
+ export declare const configDir: (env?: NodeJS.ProcessEnv) => string;
8
+ /** `--profile`, else HERMETICA_PROFILE, else `default`. */
9
+ export declare const profileOf: (env: NodeJS.ProcessEnv, flag?: string) => string;
10
+ /** One profile's login, overridden by HERMETICA_API_KEY / HERMETICA_API_URL. Null when neither gives a key. */
11
+ export declare function loadConfig(env?: NodeJS.ProcessEnv, profile?: string): Promise<CliConfig | null>;
12
+ export declare const baseUrlOf: (env: NodeJS.ProcessEnv, saved?: string, flag?: string) => string;
13
+ export declare const savedBaseUrl: (env: NodeJS.ProcessEnv, profile: string) => Promise<string | undefined>;
14
+ export declare function saveConfig(cfg: CliConfig, env?: NodeJS.ProcessEnv, profile?: string): Promise<string>;
15
+ export declare function clearConfig(env?: NodeJS.ProcessEnv, profile?: string): Promise<void>;
package/dist/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ export const DEFAULT_URL = 'https://hermeticor.com';
5
+ /** `$XDG_CONFIG_HOME/hermetica` (default `~/.config/hermetica`). */
6
+ export const configDir = (env = process.env) => join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'hermetica');
7
+ const file = (env) => join(configDir(env), 'credentials');
8
+ /** `--profile`, else HERMETICA_PROFILE, else `default`. */
9
+ export const profileOf = (env, flag) => flag || env.HERMETICA_PROFILE || 'default';
10
+ async function readAll(env) {
11
+ try {
12
+ return JSON.parse(await readFile(file(env), 'utf8'));
13
+ }
14
+ catch {
15
+ return {};
16
+ } // not logged in yet
17
+ }
18
+ /** One profile's login, overridden by HERMETICA_API_KEY / HERMETICA_API_URL. Null when neither gives a key. */
19
+ export async function loadConfig(env = process.env, profile = profileOf(env)) {
20
+ const saved = (await readAll(env))[profile] ?? {};
21
+ const apiKey = env.HERMETICA_API_KEY || saved.apiKey;
22
+ return apiKey ? { apiKey, baseUrl: baseUrlOf(env, saved.baseUrl) } : null;
23
+ }
24
+ export const baseUrlOf = (env, saved, flag) => (flag || env.HERMETICA_API_URL || saved || DEFAULT_URL).replace(/\/+$/, '');
25
+ export const savedBaseUrl = async (env, profile) => (await readAll(env))[profile]?.baseUrl;
26
+ /** The key is a secret: the directory is 0700 and the file 0600, set again on every write in case it existed with wider modes. */
27
+ async function writeAll(all, env) {
28
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
29
+ await chmod(configDir(env), 0o700);
30
+ await writeFile(file(env), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
31
+ await chmod(file(env), 0o600);
32
+ return file(env);
33
+ }
34
+ export async function saveConfig(cfg, env = process.env, profile = profileOf(env)) {
35
+ return writeAll({ ...(await readAll(env)), [profile]: cfg }, env);
36
+ }
37
+ export async function clearConfig(env = process.env, profile = profileOf(env)) {
38
+ const all = await readAll(env);
39
+ if (!(profile in all))
40
+ return;
41
+ delete all[profile];
42
+ await writeAll(all, env);
43
+ }
@@ -0,0 +1,18 @@
1
+ import type { Client } from './client.ts';
2
+ import type { Io } from './commands.ts';
3
+ /** `plan` (dry run) or `apply` the declarative config; only differences are listed, with a count line. */
4
+ export declare function planOrApply(io: Io, client: Client, apply: boolean, f: {
5
+ file?: string | undefined;
6
+ prune?: boolean | undefined;
7
+ }): Promise<void>;
8
+ export declare const SANDBOX_DOMAIN = "sandbox.hermeticor.test";
9
+ export interface SendFlags {
10
+ sandbox?: boolean | undefined;
11
+ outcome?: string | undefined;
12
+ to?: string | undefined;
13
+ from?: string | undefined;
14
+ subject?: string | undefined;
15
+ text?: string | undefined;
16
+ }
17
+ /** A raw test email to a sandbox address: no provider needed, and the outcome (delivered, bounce, ...) is chosen by the address. */
18
+ export declare function sendSandbox(io: Io, client: Client, f: SendFlags): Promise<void>;
@@ -0,0 +1,47 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parse as parseYaml } from 'yaml';
3
+ const SIGN = { create: '+', update: '~', archive: '-', noop: '=' };
4
+ /** `.yml`/`.yaml` files are YAML, anything else JSON (the same document shape). */
5
+ async function readConfigFile(path) {
6
+ if (!path)
7
+ throw new Error('Give the config file: -f hermetica.yaml (or .json)');
8
+ const yaml = /\.ya?ml$/i.test(path);
9
+ const text = await readFile(path, 'utf8').catch((e) => { throw new Error(`Cannot read ${path}`, { cause: e }); });
10
+ let doc;
11
+ try {
12
+ doc = yaml ? parseYaml(text) : JSON.parse(text);
13
+ }
14
+ catch (e) {
15
+ throw new Error(`${path} is not valid ${yaml ? 'YAML' : 'JSON'}`, { cause: e });
16
+ }
17
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
18
+ throw new Error(`${path} must hold one object (topics, fields, lists, ...)`);
19
+ return doc;
20
+ }
21
+ /** `plan` (dry run) or `apply` the declarative config; only differences are listed, with a count line. */
22
+ export async function planOrApply(io, client, apply, f) {
23
+ const doc = await readConfigFile(f.file);
24
+ const r = (await client.request('PUT', '/v1/config', { query: apply ? {} : { dryRun: 'true' }, body: f.prune ? { ...doc, prune: true } : doc }));
25
+ for (const c of r.changes)
26
+ if (c.action !== 'noop')
27
+ io.out(`${SIGN[c.action]} ${c.kind} ${c.key}${c.fields?.length ? ` (${c.fields.join(', ')})` : ''}`);
28
+ const n = (a) => r.changes.filter((c) => c.action === a).length;
29
+ io.out(`${apply ? 'Applied' : 'Plan'}: ${n('create')} to create, ${n('update')} to update, ${n('archive')} to archive, ${n('noop')} unchanged.`);
30
+ }
31
+ export const SANDBOX_DOMAIN = 'sandbox.hermeticor.test';
32
+ const OUTCOMES = ['delivered', 'bounce', 'soft-bounce', 'complaint'];
33
+ /** A raw test email to a sandbox address: no provider needed, and the outcome (delivered, bounce, ...) is chosen by the address. */
34
+ export async function sendSandbox(io, client, f) {
35
+ if (!f.sandbox)
36
+ throw new Error('Only sandbox sends for now: hermetica send --sandbox [--outcome bounce] (use "openapi" for real sends)');
37
+ if (f.outcome && !OUTCOMES.includes(f.outcome))
38
+ throw new Error(`--outcome is one of ${OUTCOMES.join(', ')}`);
39
+ const to = f.to ?? `${f.outcome ?? 'delivered'}@${SANDBOX_DOMAIN}`;
40
+ if (!to.toLowerCase().endsWith(`@${SANDBOX_DOMAIN}`))
41
+ throw new Error(`--sandbox sends only to <outcome>@${SANDBOX_DOMAIN}`);
42
+ const rawMime = [
43
+ `From: ${f.from ?? `cli@${SANDBOX_DOMAIN}`}`, `To: ${to}`, `Subject: ${f.subject ?? 'Hermetica CLI sandbox test'}`, 'MIME-Version: 1.0', 'Content-Type: text/plain; charset=utf-8', '',
44
+ f.text ?? 'Sent with hermetica send --sandbox.',
45
+ ].join('\r\n');
46
+ io.out(JSON.stringify(await client.request('POST', '/v1/transactional/send-raw', { body: { rawMime, to: [to] } }), null, 2));
47
+ }
@@ -0,0 +1,4 @@
1
+ export { run, HELP, RESOURCES, type Io } from './commands.ts';
2
+ export { makeClient, ApiError, type Client } from './client.ts';
3
+ export { loadConfig, saveConfig, clearConfig, configDir, profileOf, type CliConfig } from './config.ts';
4
+ export { buildCall, operations, type Operation } from './openapi.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { run, HELP, RESOURCES } from './commands.js';
2
+ export { makeClient, ApiError } from './client.js';
3
+ export { loadConfig, saveConfig, clearConfig, configDir, profileOf } from './config.js';
4
+ export { buildCall, operations } from './openapi.js';
@@ -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/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { ApiError } from './client.js';
5
+ import { askKey, run } from './commands.js';
6
+ /** Best effort: with no desktop opener the printed URL is enough. */
7
+ const openUrl = (url) => {
8
+ const [cmd, ...args] = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url];
9
+ try {
10
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
11
+ }
12
+ catch { /* no opener */ }
13
+ };
14
+ try {
15
+ await run(process.argv.slice(2), { env: process.env, fetch, out: (s) => process.stdout.write(`${s}\n`), sleep: (ms) => sleep(ms), askKey, openUrl });
16
+ }
17
+ catch (e) {
18
+ const msg = e instanceof ApiError ? `${e.status} ${e.code}: ${e.message}${e.details ? `\n${JSON.stringify(e.details, null, 2)}` : ''}` : e instanceof Error ? e.message : String(e);
19
+ process.stderr.write(`${msg}\n`);
20
+ process.exitCode = 1;
21
+ }
@@ -0,0 +1,25 @@
1
+ import type { Client, Query } from './client.ts';
2
+ type Op = {
3
+ operationId?: string;
4
+ summary?: string;
5
+ };
6
+ export interface Spec {
7
+ paths?: Record<string, Record<string, Op>>;
8
+ }
9
+ export interface Operation {
10
+ id: string;
11
+ method: string;
12
+ path: string;
13
+ summary: string;
14
+ }
15
+ /** Every operation in the server's OpenAPI document, in document order. */
16
+ export declare const operations: (spec: Spec) => Operation[];
17
+ /** `key=value` pairs fill the path's `{key}` placeholders; the rest become the query string. A placeholder left unfilled is an error. */
18
+ export declare function buildCall(op: Operation, pairs: Record<string, string>): {
19
+ method: string;
20
+ path: string;
21
+ query: Query;
22
+ };
23
+ export declare const fetchSpec: (client: Client) => Promise<Spec>;
24
+ export declare function findOperation(spec: Spec, id: string): Operation;
25
+ export {};
@@ -0,0 +1,23 @@
1
+ const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
2
+ /** Every operation in the server's OpenAPI document, in document order. */
3
+ export const operations = (spec) => Object.entries(spec.paths ?? {}).flatMap(([path, ops]) => Object.entries(ops).filter(([m, op]) => METHODS.has(m) && op?.operationId).map(([m, op]) => ({ id: op.operationId, method: m.toUpperCase(), path, summary: op.summary ?? '' })));
4
+ /** `key=value` pairs fill the path's `{key}` placeholders; the rest become the query string. A placeholder left unfilled is an error. */
5
+ export function buildCall(op, pairs) {
6
+ const query = { ...pairs };
7
+ const path = op.path.replace(/\{([^}]+)\}/g, (_m, name) => {
8
+ const v = pairs[name];
9
+ if (v === undefined)
10
+ throw new Error(`${op.id} needs ${name}=<value>`);
11
+ delete query[name];
12
+ return encodeURIComponent(v);
13
+ });
14
+ return { method: op.method, path, query };
15
+ }
16
+ export const fetchSpec = async (client) => (await client.request('GET', '/openapi.json'));
17
+ export function findOperation(spec, id) {
18
+ const all = operations(spec);
19
+ const op = all.find((o) => o.id === id) ?? all.find((o) => o.id.toLowerCase() === id.toLowerCase());
20
+ if (!op)
21
+ throw new Error(`No operation ${id}. Run "hermetica openapi list ${id.slice(0, 12)}" to search.`);
22
+ return op;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@hermeticacomms/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "Hermetica command line: device login, config as code, sandbox sends, any API operation",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hermeticacomms/hermetica-cli.git"
10
+ },
11
+ "homepage": "https://github.com/hermeticacomms/hermetica-cli#readme",
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "provenance": true
15
+ },
16
+ "bin": {
17
+ "hermetica": "./dist/main.js"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "CHANGELOG.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "scripts": {
33
+ "prepare": "npm run build",
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "vitest run",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit"
37
+ },
38
+ "dependencies": {
39
+ "yaml": "^2.9.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^26.6.6",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^5.0.5"
45
+ }
46
+ }