@hermeticacomms/cli 0.0.0-stage → 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.
- package/LICENSE +9 -0
- package/README.md +51 -2
- package/dist/account.d.ts +27 -0
- package/dist/account.js +82 -0
- package/dist/args.d.ts +4 -0
- package/dist/args.js +26 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +35 -0
- package/dist/commands.d.ts +21 -0
- package/dist/commands.js +166 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +43 -0
- package/dist/declare.d.ts +18 -0
- package/dist/declare.js +47 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +21 -0
- package/dist/openapi.d.ts +25 -0
- package/dist/openapi.js +23 -0
- package/package.json +43 -4
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,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hermeticacomms/cli
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
## Everything else
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
hermetica profiles list email=ada@example.com
|
|
44
|
+
hermetica campaigns create --data @campaign.json
|
|
45
|
+
hermetica events tail name=signed_up
|
|
46
|
+
hermetica export lists > lists.jsonl
|
|
47
|
+
hermetica openapi list segments # every operation of the server's OpenAPI document
|
|
48
|
+
hermetica openapi getV1SegmentsByIdMembers id=<segmentId> limit=10
|
|
49
|
+
hermetica help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
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 {};
|
package/dist/account.js
ADDED
|
@@ -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
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -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. */
|
|
21
|
+
export declare function askKey(): Promise<string>;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { pairs, readData } from './args.js';
|
|
4
|
+
import { makeClient } from './client.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { login, logout, requestScopes, status, whoami } from './account.js';
|
|
7
|
+
import { planOrApply, sendSandbox } from './declare.js';
|
|
8
|
+
import { buildCall, fetchSpec, findOperation, operations } from './openapi.js';
|
|
9
|
+
export const RESOURCES = {
|
|
10
|
+
profiles: '/v1/profiles', lists: '/v1/lists', campaigns: '/v1/campaigns', sequences: '/v1/outbound/sequences', templates: '/v1/templates',
|
|
11
|
+
};
|
|
12
|
+
/** list/get only: creating these goes through flows (connect a mailbox, a lead search, a received reply) the CLI does not model. */
|
|
13
|
+
export const READ_ONLY = { mailboxes: '/v1/outbound/mailboxes', leads: '/v1/leads', inbox: '/v1/outbound/inbox' };
|
|
14
|
+
const LISTABLE = { ...RESOURCES, ...READ_ONLY };
|
|
15
|
+
export const HELP = `hermetica <command>
|
|
16
|
+
|
|
17
|
+
login [--scopes a:read,b:write] [--url https://...] sign in through the browser (device code); the key is stored, never shown
|
|
18
|
+
login --key hm_sk_... | --paste store an existing secret key instead
|
|
19
|
+
logout forget the stored key of this profile
|
|
20
|
+
whoami who the stored key acts as, and its scopes
|
|
21
|
+
status what blocks sending, with the API call that fixes each step
|
|
22
|
+
plan -f hermetica.yaml [--prune] show what apply would change (config as code, YAML or JSON)
|
|
23
|
+
apply -f hermetica.yaml [--prune] create/update to match the file; --prune archives what it leaves out
|
|
24
|
+
send --sandbox [--outcome bounce|soft-bounce|complaint] test email to <outcome>@sandbox.hermeticor.test, no provider needed
|
|
25
|
+
scopes request <scope> [...] --reason "..." ask an owner for more scopes and wait for the answer
|
|
26
|
+
<resource> list [key=value ...] resources: ${Object.keys(RESOURCES).join(', ')}
|
|
27
|
+
<resource> get <id>
|
|
28
|
+
<resource> create --data '<json>' | @file.json | -
|
|
29
|
+
${Object.keys(READ_ONLY).join(', ')} list [key=value ...] | get <id>
|
|
30
|
+
enrollments list <sequenceId> [key=value ...] prospects enrolled in a sequence or workflow
|
|
31
|
+
enrollments get <sequenceId> <enrollmentId> the step trail of one enrollment
|
|
32
|
+
send-test campaign <id> --to a@x.com[,b@y.com] or --data '<json>' (templates need seedListId and connectionId)
|
|
33
|
+
send-test template <id> --data '<json>'
|
|
34
|
+
events tail [name=<event>] [--interval 5] print new events as JSON lines until Ctrl-C
|
|
35
|
+
export <resource> [key=value ...] every page as JSON lines
|
|
36
|
+
openapi list [search] operations of the server's OpenAPI document
|
|
37
|
+
openapi <operationId> [key=value ...] [--data ...] call any operation: path {params} first, the rest as query
|
|
38
|
+
|
|
39
|
+
--profile <name> (or HERMETICA_PROFILE) picks a stored login; HERMETICA_API_KEY and HERMETICA_API_URL override it.`;
|
|
40
|
+
const json = (x) => JSON.stringify(x, null, 2);
|
|
41
|
+
async function* pages(client, path, query) {
|
|
42
|
+
let cursor;
|
|
43
|
+
do {
|
|
44
|
+
const p = (await client.request('GET', path, { query: { limit: 200, ...query, ...(cursor ? { cursor } : {}) } }));
|
|
45
|
+
yield p.items ?? [];
|
|
46
|
+
cursor = p.nextCursor ?? undefined;
|
|
47
|
+
} while (cursor);
|
|
48
|
+
}
|
|
49
|
+
/** Newest-first event pages until a seen id: UUIDv7 ids sort by arrival, so everything above `lastId` is new. Returned oldest first. */
|
|
50
|
+
export async function newEvents(client, lastId, query) {
|
|
51
|
+
const fresh = [];
|
|
52
|
+
let cursor;
|
|
53
|
+
for (let i = 0; i < 10; i++) {
|
|
54
|
+
const p = (await client.request('GET', '/v1/events', { query: { limit: 100, ...query, ...(cursor ? { cursor } : {}) } }));
|
|
55
|
+
const mine = lastId ? p.items.filter((e) => e.id > lastId) : p.items;
|
|
56
|
+
fresh.push(...mine);
|
|
57
|
+
// The first poll only shows the latest page, like tail.
|
|
58
|
+
if (!lastId || mine.length < p.items.length || !p.nextCursor)
|
|
59
|
+
break;
|
|
60
|
+
cursor = p.nextCursor;
|
|
61
|
+
}
|
|
62
|
+
return fresh.toReversed();
|
|
63
|
+
}
|
|
64
|
+
/** Runs one command line. Throws on bad input or an API error; main.ts prints it and sets the exit code. */
|
|
65
|
+
export async function run(argv, io) {
|
|
66
|
+
const { values: f, positionals: [cmd, ...rest] } = parseArgs({
|
|
67
|
+
args: argv, allowPositionals: true,
|
|
68
|
+
options: {
|
|
69
|
+
key: { type: 'string' }, url: { type: 'string' }, data: { type: 'string', short: 'd' }, to: { type: 'string' }, interval: { type: 'string' }, help: { type: 'boolean', short: 'h' },
|
|
70
|
+
profile: { type: 'string' }, scopes: { type: 'string' }, paste: { type: 'boolean' }, file: { type: 'string', short: 'f' }, prune: { type: 'boolean' },
|
|
71
|
+
sandbox: { type: 'boolean' }, outcome: { type: 'string' }, from: { type: 'string' }, subject: { type: 'string' }, text: { type: 'string' }, reason: { type: 'string' },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
if (!cmd || f.help || cmd === 'help')
|
|
75
|
+
return io.out(HELP);
|
|
76
|
+
if (cmd === 'login')
|
|
77
|
+
return login(io, f);
|
|
78
|
+
if (cmd === 'logout')
|
|
79
|
+
return logout(io, f.profile);
|
|
80
|
+
const cfg = await loadConfig(io.env, f.profile || undefined);
|
|
81
|
+
if (!cfg)
|
|
82
|
+
throw new Error('Not logged in. Run "hermetica login" or set HERMETICA_API_KEY.');
|
|
83
|
+
const client = makeClient(cfg, io.fetch);
|
|
84
|
+
const [a, b, ...more] = rest;
|
|
85
|
+
if (cmd === 'whoami')
|
|
86
|
+
return whoami(io, client);
|
|
87
|
+
if (cmd === 'status')
|
|
88
|
+
return status(io, client);
|
|
89
|
+
if (cmd === 'plan' || cmd === 'apply')
|
|
90
|
+
return planOrApply(io, client, cmd === 'apply', f);
|
|
91
|
+
if (cmd === 'send')
|
|
92
|
+
return sendSandbox(io, client, f);
|
|
93
|
+
if (cmd === 'scopes' && a === 'request')
|
|
94
|
+
return requestScopes(io, client, [b, ...more].filter((x) => !!x), f.reason, Math.max(1, Number(f.interval ?? 5)));
|
|
95
|
+
if (cmd in LISTABLE) {
|
|
96
|
+
const base = LISTABLE[cmd];
|
|
97
|
+
if (a === 'list')
|
|
98
|
+
return io.out(json(await client.request('GET', base, { query: pairs([b, ...more].filter((x) => !!x)) })));
|
|
99
|
+
if (a === 'get' && b)
|
|
100
|
+
return io.out(json(await client.request('GET', `${base}/${encodeURIComponent(b)}`)));
|
|
101
|
+
if (a === 'create' && cmd in RESOURCES)
|
|
102
|
+
return io.out(json(await client.request('POST', base, { body: (await readData(f.data)) ?? {} })));
|
|
103
|
+
throw new Error(`Usage: hermetica ${cmd} list | get <id>${cmd in RESOURCES ? ' | create --data ...' : ''}`);
|
|
104
|
+
}
|
|
105
|
+
if (cmd === 'enrollments') {
|
|
106
|
+
// A sequence is a workflow in sequence mode, so its enrollments live under /v1/workflows/:id.
|
|
107
|
+
const [id, enrollmentId] = [b, more[0]];
|
|
108
|
+
const base = id && `/v1/workflows/${encodeURIComponent(id)}/enrollments`;
|
|
109
|
+
if (a === 'list' && base)
|
|
110
|
+
return io.out(json(await client.request('GET', base, { query: pairs(more.filter((x) => !!x)) })));
|
|
111
|
+
if (a === 'get' && base && enrollmentId)
|
|
112
|
+
return io.out(json(await client.request('GET', `${base}/${encodeURIComponent(enrollmentId)}/steps`)));
|
|
113
|
+
throw new Error('Usage: hermetica enrollments list <sequenceId> [key=value ...] | get <sequenceId> <enrollmentId>');
|
|
114
|
+
}
|
|
115
|
+
if (cmd === 'send-test') {
|
|
116
|
+
if ((a !== 'campaign' && a !== 'template') || !b)
|
|
117
|
+
throw new Error('Usage: hermetica send-test campaign|template <id> --to ... | --data ...');
|
|
118
|
+
const body = (await readData(f.data)) ?? (f.to ? { emails: f.to.split(',').map((s) => s.trim()).filter(Boolean) } : undefined);
|
|
119
|
+
if (!body)
|
|
120
|
+
throw new Error('Give --to or --data');
|
|
121
|
+
return io.out(json(await client.request('POST', `${RESOURCES[`${a}s`]}/${encodeURIComponent(b)}/test-send`, { body })));
|
|
122
|
+
}
|
|
123
|
+
if (cmd === 'events' && a === 'tail') {
|
|
124
|
+
const query = pairs([b, ...more].filter((x) => !!x));
|
|
125
|
+
const every = Math.max(1, Number(f.interval ?? 5)) * 1000;
|
|
126
|
+
let last = null;
|
|
127
|
+
for (;;) {
|
|
128
|
+
for (const e of await newEvents(client, last, query)) {
|
|
129
|
+
io.out(JSON.stringify(e));
|
|
130
|
+
if (!last || e.id > last)
|
|
131
|
+
last = e.id;
|
|
132
|
+
}
|
|
133
|
+
await io.sleep(every);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (cmd === 'export' && a && a in LISTABLE) {
|
|
137
|
+
for await (const items of pages(client, LISTABLE[a], pairs([b, ...more].filter((x) => !!x))))
|
|
138
|
+
for (const x of items)
|
|
139
|
+
io.out(JSON.stringify(x));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (cmd === 'openapi' && a) {
|
|
143
|
+
const spec = await fetchSpec(client);
|
|
144
|
+
if (a === 'list') {
|
|
145
|
+
const needle = (b ?? '').toLowerCase();
|
|
146
|
+
for (const o of operations(spec))
|
|
147
|
+
if (!needle || `${o.id} ${o.path} ${o.summary}`.toLowerCase().includes(needle))
|
|
148
|
+
io.out(`${o.id}\t${o.method} ${o.path}${o.summary ? `\t${o.summary}` : ''}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const call = buildCall(findOperation(spec, a), pairs([b, ...more].filter((x) => !!x)));
|
|
152
|
+
const body = await readData(f.data);
|
|
153
|
+
return io.out(json(await client.request(call.method, call.path, { query: call.query, ...(body === undefined ? {} : { body }) })));
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`Unknown command "${[cmd, a].filter(Boolean).join(' ')}".\n\n${HELP}`);
|
|
156
|
+
}
|
|
157
|
+
/** Reads the key from the terminal so it never lands in shell history. */
|
|
158
|
+
export async function askKey() {
|
|
159
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
160
|
+
try {
|
|
161
|
+
return await rl.question('API key (hm_sk_...): ');
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
rl.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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>;
|
package/dist/declare.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/main.d.ts
ADDED
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 {};
|
package/dist/openapi.js
ADDED
|
@@ -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,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hermeticacomms/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"prepare": "npm run build",
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"yaml": "^2.9.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^26.6.6",
|
|
42
|
+
"typescript": "^7.0.2",
|
|
43
|
+
"vitest": "^5.0.5"
|
|
44
|
+
}
|
|
45
|
+
}
|