@hogsend/cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ import type { ResolvedConfig } from "../lib/config.js";
2
+ import type { AdminClient } from "../lib/http.js";
3
+ import type { Output } from "../lib/output.js";
4
+
5
+ /**
6
+ * Per-invocation context handed to every command's `run()`. The router builds
7
+ * this once (after parsing global flags + resolving config) and passes it in,
8
+ * so command files never touch process.argv directly, never resolve config,
9
+ * and never construct an HTTP client themselves.
10
+ */
11
+ export interface CommandContext {
12
+ /**
13
+ * The args AFTER the command token. e.g. for `hogsend journeys list --json`
14
+ * the router strips `journeys` and the global `--json`, leaving `["list"]`.
15
+ * Subcommand dispatch (list/get/...) is the command's own responsibility.
16
+ */
17
+ argv: string[];
18
+ /** Base URL + admin key, already resolved via flags > env > .env. */
19
+ cfg: ResolvedConfig;
20
+ /** Pre-built admin HTTP client, bound to `cfg`. */
21
+ http: AdminClient;
22
+ /** Output sink — human (TTY clack) vs json, already mode-selected. */
23
+ out: Output;
24
+ /** True when the global `--json` flag was passed. Mirrors `out.isJson`. */
25
+ json: boolean;
26
+ }
27
+
28
+ /**
29
+ * The descriptor every command file implements. The router matches `name`
30
+ * against the leading argv token and dispatches to `run()`.
31
+ */
32
+ export interface Command {
33
+ /** Command token, e.g. "journeys", "eject". */
34
+ name: string;
35
+ /** One-line help shown in the root command list. */
36
+ summary: string;
37
+ /** Multiline usage block shown on `hogsend <name> --help`. */
38
+ usage: string;
39
+ /** Execute the command. Throw to fail (router renders + exits 1). */
40
+ run(ctx: CommandContext): Promise<void>;
41
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  // @hogsend/cli — programmatic entry. The `hogsend` bin lives in ./bin.ts.
2
+
3
+ export type { Command, CommandContext } from "./commands/types.js";
2
4
  export {
3
5
  EjectError,
4
6
  type EjectOptions,
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+
5
+ /** Resolved target for the admin HTTP client. */
6
+ export interface ResolvedConfig {
7
+ /** Base URL of the target instance, no trailing slash. */
8
+ baseUrl: string;
9
+ /** Admin bearer token, if resolvable. `doctor`/health works without it. */
10
+ adminKey: string | undefined;
11
+ }
12
+
13
+ /** Global flags parsed off the front of any command's argv. */
14
+ export interface GlobalFlags {
15
+ url?: string;
16
+ adminKey?: string;
17
+ json: boolean;
18
+ help: boolean;
19
+ /** The remaining args after global flags are stripped. */
20
+ rest: string[];
21
+ }
22
+
23
+ const DEFAULT_BASE_URL = "http://localhost:3002";
24
+
25
+ /**
26
+ * Parse the global flags that every command honours (`--url`, `--admin-key`,
27
+ * `--json`, `-h`/`--help`) off an argv slice, returning the parsed values plus
28
+ * the leftover `rest` (positionals + unknown flags) for the command to handle.
29
+ *
30
+ * `strict: false` so command-specific flags (e.g. `--enabled`, `--limit`) pass
31
+ * through untouched in `rest` rather than throwing here.
32
+ */
33
+ export function parseGlobalFlags(argv: string[]): GlobalFlags {
34
+ const { values, tokens } = parseArgs({
35
+ args: argv,
36
+ allowPositionals: true,
37
+ strict: false,
38
+ tokens: true,
39
+ options: {
40
+ url: { type: "string" },
41
+ "admin-key": { type: "string" },
42
+ json: { type: "boolean", default: false },
43
+ help: { type: "boolean", short: "h", default: false },
44
+ },
45
+ });
46
+
47
+ // Rebuild `rest` from the token stream, dropping only the global flags we
48
+ // own (and their values). Everything else — positionals and unknown option
49
+ // tokens — is preserved verbatim for the command's own parser.
50
+ const owned = new Set(["url", "admin-key", "json", "help", "h"]);
51
+ const rest: string[] = [];
52
+ for (const token of tokens) {
53
+ if (token.kind === "positional") {
54
+ rest.push(token.value);
55
+ } else if (token.kind === "option") {
56
+ if (owned.has(token.name)) continue;
57
+ rest.push(token.rawName);
58
+ if (token.value !== undefined && !token.inlineValue) {
59
+ rest.push(token.value);
60
+ } else if (token.inlineValue && token.value !== undefined) {
61
+ // already captured in rawName? no — rebuild as --name=value
62
+ rest[rest.length - 1] = `${token.rawName}=${token.value}`;
63
+ }
64
+ }
65
+ }
66
+
67
+ return {
68
+ url: typeof values.url === "string" ? values.url : undefined,
69
+ adminKey:
70
+ typeof values["admin-key"] === "string" ? values["admin-key"] : undefined,
71
+ json: values.json === true,
72
+ help: values.help === true,
73
+ rest,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Manually parse a `.env` file into a flat record. No dotenv dependency: a
79
+ * small, forgiving parser (KEY=VALUE per line, `#` comments, optional quotes,
80
+ * `export ` prefix tolerated). Never throws — a missing/unreadable file yields
81
+ * an empty record so config resolution stays robust in any cwd.
82
+ */
83
+ export function loadDotEnv(
84
+ cwd: string = process.cwd(),
85
+ ): Record<string, string> {
86
+ const out: Record<string, string> = {};
87
+ const file = join(cwd, ".env");
88
+ if (!existsSync(file)) return out;
89
+ let raw: string;
90
+ try {
91
+ raw = readFileSync(file, "utf8");
92
+ } catch {
93
+ return out;
94
+ }
95
+ for (const rawLine of raw.split(/\r?\n/)) {
96
+ const line = rawLine.trim();
97
+ if (line === "" || line.startsWith("#")) continue;
98
+ const withoutExport = line.startsWith("export ")
99
+ ? line.slice("export ".length)
100
+ : line;
101
+ const eq = withoutExport.indexOf("=");
102
+ if (eq === -1) continue;
103
+ const key = withoutExport.slice(0, eq).trim();
104
+ if (key === "") continue;
105
+ let value = withoutExport.slice(eq + 1).trim();
106
+ if (
107
+ (value.startsWith('"') && value.endsWith('"')) ||
108
+ (value.startsWith("'") && value.endsWith("'"))
109
+ ) {
110
+ value = value.slice(1, -1);
111
+ }
112
+ out[key] = value;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Resolve the target config with precedence flags > process.env > .env, falling
119
+ * back to the local-dev default base URL.
120
+ *
121
+ * baseUrl: --url > HOGSEND_API_URL (env) > HOGSEND_API_URL (.env) > localhost:3002
122
+ * adminKey: --admin-key > HOGSEND_ADMIN_KEY|ADMIN_API_KEY (env) > (.env equiv)
123
+ */
124
+ export function resolveConfig(
125
+ flags: GlobalFlags,
126
+ cwd: string = process.cwd(),
127
+ ): ResolvedConfig {
128
+ const dotenv = loadDotEnv(cwd);
129
+
130
+ const baseUrlRaw =
131
+ flags.url ??
132
+ process.env.HOGSEND_API_URL ??
133
+ dotenv.HOGSEND_API_URL ??
134
+ DEFAULT_BASE_URL;
135
+
136
+ const adminKey =
137
+ flags.adminKey ??
138
+ process.env.HOGSEND_ADMIN_KEY ??
139
+ process.env.ADMIN_API_KEY ??
140
+ dotenv.HOGSEND_ADMIN_KEY ??
141
+ dotenv.ADMIN_API_KEY;
142
+
143
+ return {
144
+ baseUrl: baseUrlRaw.replace(/\/+$/, ""),
145
+ adminKey: adminKey && adminKey.length > 0 ? adminKey : undefined,
146
+ };
147
+ }
@@ -0,0 +1,145 @@
1
+ import type { ResolvedConfig } from "./config.js";
2
+
3
+ /** A non-2xx response (or transport failure) from the admin API. */
4
+ export interface HttpError extends Error {
5
+ /** HTTP status code, or 0 for a transport-level failure (DNS/connect). */
6
+ status: number;
7
+ /** Parsed JSON body when available, else the raw text, else undefined. */
8
+ body: unknown;
9
+ }
10
+
11
+ /** Query params accepted by `get` — undefined values are dropped. */
12
+ export type Query = Record<string, string | number | undefined>;
13
+
14
+ /**
15
+ * Thin admin HTTP client over native fetch (Node 22). Hits `<base>/v1/...`,
16
+ * sends `Authorization: Bearer <adminKey>` on admin paths, parses JSON, and
17
+ * throws an {@link HttpError} on any non-2xx response.
18
+ *
19
+ * Path convention: pass the path AFTER the base URL, e.g. `/v1/admin/journeys`
20
+ * or `/v1/health`. The unauthenticated health route is reached via the same
21
+ * `get` — pass `{ auth: false }` so a missing admin key doesn't error.
22
+ */
23
+ export interface AdminClient {
24
+ get<T = unknown>(
25
+ path: string,
26
+ query?: Query,
27
+ opts?: RequestExtras,
28
+ ): Promise<T>;
29
+ patch<T = unknown>(path: string, body: unknown): Promise<T>;
30
+ post<T = unknown>(path: string, body: unknown): Promise<T>;
31
+ /** The resolved config this client is bound to (for messages/JSON output). */
32
+ readonly cfg: ResolvedConfig;
33
+ }
34
+
35
+ /** Per-request overrides. */
36
+ export interface RequestExtras {
37
+ /** Set false for unauthenticated routes (e.g. /v1/health). Default true. */
38
+ auth?: boolean;
39
+ }
40
+
41
+ function isHttpError(value: unknown): value is HttpError {
42
+ return value instanceof Error && "status" in value;
43
+ }
44
+
45
+ function makeHttpError(
46
+ message: string,
47
+ status: number,
48
+ body: unknown,
49
+ ): HttpError {
50
+ const err = new Error(message) as HttpError;
51
+ err.name = "HttpError";
52
+ err.status = status;
53
+ err.body = body;
54
+ return err;
55
+ }
56
+
57
+ function buildUrl(baseUrl: string, path: string, query?: Query): string {
58
+ const url = new URL(path.startsWith("/") ? path : `/${path}`, `${baseUrl}/`);
59
+ if (query) {
60
+ for (const [key, value] of Object.entries(query)) {
61
+ if (value === undefined) continue;
62
+ url.searchParams.set(key, String(value));
63
+ }
64
+ }
65
+ return url.toString();
66
+ }
67
+
68
+ function bodyMessage(status: number, body: unknown): string {
69
+ if (
70
+ body &&
71
+ typeof body === "object" &&
72
+ "error" in body &&
73
+ typeof (body as { error: unknown }).error === "string"
74
+ ) {
75
+ return `${status}: ${(body as { error: string }).error}`;
76
+ }
77
+ return `request failed with status ${status}`;
78
+ }
79
+
80
+ /** Build an {@link AdminClient} bound to the given resolved config. */
81
+ export function createAdminClient(cfg: ResolvedConfig): AdminClient {
82
+ async function request<T>(
83
+ method: string,
84
+ path: string,
85
+ opts: { query?: Query; body?: unknown; auth: boolean },
86
+ ): Promise<T> {
87
+ if (opts.auth && !cfg.adminKey) {
88
+ throw makeHttpError(
89
+ "no admin key configured — pass --admin-key, or set HOGSEND_ADMIN_KEY / ADMIN_API_KEY",
90
+ 0,
91
+ undefined,
92
+ );
93
+ }
94
+
95
+ const headers: Record<string, string> = { Accept: "application/json" };
96
+ if (opts.auth && cfg.adminKey) {
97
+ headers.Authorization = `Bearer ${cfg.adminKey}`;
98
+ }
99
+ if (opts.body !== undefined) {
100
+ headers["Content-Type"] = "application/json";
101
+ }
102
+
103
+ const url = buildUrl(cfg.baseUrl, path, opts.query);
104
+
105
+ let res: Response;
106
+ try {
107
+ res = await fetch(url, {
108
+ method,
109
+ headers,
110
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
111
+ });
112
+ } catch (cause) {
113
+ const msg = cause instanceof Error ? cause.message : String(cause);
114
+ throw makeHttpError(`cannot reach ${cfg.baseUrl} (${msg})`, 0, undefined);
115
+ }
116
+
117
+ const text = await res.text();
118
+ let parsed: unknown;
119
+ if (text.length > 0) {
120
+ try {
121
+ parsed = JSON.parse(text);
122
+ } catch {
123
+ parsed = text;
124
+ }
125
+ }
126
+
127
+ if (!res.ok) {
128
+ throw makeHttpError(bodyMessage(res.status, parsed), res.status, parsed);
129
+ }
130
+
131
+ return parsed as T;
132
+ }
133
+
134
+ return {
135
+ cfg,
136
+ get: <T>(path: string, query?: Query, extras?: RequestExtras) =>
137
+ request<T>("GET", path, { query, auth: extras?.auth ?? true }),
138
+ patch: <T>(path: string, body: unknown) =>
139
+ request<T>("PATCH", path, { body, auth: true }),
140
+ post: <T>(path: string, body: unknown) =>
141
+ request<T>("POST", path, { body, auth: true }),
142
+ };
143
+ }
144
+
145
+ export { isHttpError };
@@ -0,0 +1,185 @@
1
+ import {
2
+ cancel,
3
+ intro as clackIntro,
4
+ note as clackNote,
5
+ outro as clackOutro,
6
+ spinner,
7
+ } from "@clack/prompts";
8
+ import color from "picocolors";
9
+
10
+ /**
11
+ * Unified output sink. Two modes:
12
+ *
13
+ * - human: TTY clack chrome (intro badge, spinners, notes, outro) + tables.
14
+ * Falls back to plain console.log lines when stdout is not a TTY.
15
+ * - json (`--json`): ALL chrome is a no-op; the command emits exactly one
16
+ * JSON document via `out.json(payload)`. Nothing else touches stdout, so a
17
+ * --json run is always a single valid JSON document — safe for agents.
18
+ *
19
+ * `interactive` is true only when human mode AND stdout is a TTY; commands key
20
+ * spinner/prompt behaviour off it. `isJson` flips command control flow to the
21
+ * non-interactive branch.
22
+ */
23
+ export interface Output {
24
+ /** True when human-mode AND stdout is a TTY (clack chrome is live). */
25
+ readonly interactive: boolean;
26
+ /** True when `--json` was passed. */
27
+ readonly isJson: boolean;
28
+ /** Session intro badge. No-op in json / non-TTY. */
29
+ intro(title: string): void;
30
+ /**
31
+ * Run an async step with a spinner in interactive mode; a plain awaited call
32
+ * otherwise. The label is logged (not spun) when non-interactive & not json.
33
+ */
34
+ step<T>(label: string, fn: () => Promise<T>): Promise<T>;
35
+ /** Boxed note. No-op in json / non-TTY (prints plain lines in non-TTY human). */
36
+ note(body: string, title?: string): void;
37
+ /** Render an array of records as a table (human only; no-op in json). */
38
+ table(rows: Record<string, unknown>[], columns?: string[]): void;
39
+ /** Render a key/value object (human only; no-op in json). */
40
+ kv(obj: Record<string, unknown>, title?: string): void;
41
+ /** Plain human/plain-text line. No-op in json. */
42
+ log(msg: string): void;
43
+ /** Emit the single JSON document. Only meaningful in json mode. */
44
+ json(payload: unknown): void;
45
+ /** Session outro. No-op in json / non-TTY. */
46
+ outro(msg: string): void;
47
+ /**
48
+ * Fail terminally. json: prints `{ "error": message }` to stdout, exit 1.
49
+ * human (TTY): clack cancel(message), exit 1. human (non-TTY): stderr, exit 1.
50
+ */
51
+ fail(message: string): never;
52
+ }
53
+
54
+ function renderTable(
55
+ rows: Record<string, unknown>[],
56
+ columns?: string[],
57
+ ): string {
58
+ if (rows.length === 0) return color.dim("(no rows)");
59
+ const cols =
60
+ columns ??
61
+ Array.from(
62
+ rows.reduce<Set<string>>((set, row) => {
63
+ for (const key of Object.keys(row)) set.add(key);
64
+ return set;
65
+ }, new Set<string>()),
66
+ );
67
+ const cell = (value: unknown): string => {
68
+ if (value === null || value === undefined) return "";
69
+ if (typeof value === "object") return JSON.stringify(value);
70
+ return String(value);
71
+ };
72
+ const widths = cols.map((c) =>
73
+ Math.max(c.length, ...rows.map((r) => cell(r[c]).length)),
74
+ );
75
+ const pad = (text: string, width: number) =>
76
+ text + " ".repeat(width - text.length);
77
+ const header = cols
78
+ .map((c, i) => color.bold(pad(c, widths[i] ?? 0)))
79
+ .join(" ");
80
+ const sep = cols.map((_, i) => "-".repeat(widths[i] ?? 0)).join(" ");
81
+ const body = rows
82
+ .map((r) => cols.map((c, i) => pad(cell(r[c]), widths[i] ?? 0)).join(" "))
83
+ .join("\n");
84
+ return `${header}\n${color.dim(sep)}\n${body}`;
85
+ }
86
+
87
+ function renderKv(obj: Record<string, unknown>): string {
88
+ const keys = Object.keys(obj);
89
+ if (keys.length === 0) return color.dim("(empty)");
90
+ const width = Math.max(...keys.map((k) => k.length));
91
+ return keys
92
+ .map((k) => {
93
+ const v = obj[k];
94
+ const str =
95
+ v === null || v === undefined
96
+ ? ""
97
+ : typeof v === "object"
98
+ ? JSON.stringify(v)
99
+ : String(v);
100
+ return `${color.dim(`${k}:`.padEnd(width + 1))} ${str}`;
101
+ })
102
+ .join("\n");
103
+ }
104
+
105
+ /** Build an {@link Output} for the given mode. */
106
+ export function createOutput(opts: { json: boolean }): Output {
107
+ const isJson = opts.json;
108
+ const interactive = !isJson && Boolean(process.stdout.isTTY);
109
+
110
+ return {
111
+ interactive,
112
+ isJson,
113
+
114
+ intro(title) {
115
+ if (!interactive) return;
116
+ clackIntro(title);
117
+ },
118
+
119
+ async step<T>(label: string, fn: () => Promise<T>): Promise<T> {
120
+ if (interactive) {
121
+ const s = spinner();
122
+ s.start(label);
123
+ try {
124
+ const result = await fn();
125
+ s.stop(`${color.green("✓")} ${label}`);
126
+ return result;
127
+ } catch (err) {
128
+ s.stop(`${color.red("✗")} ${label}`);
129
+ throw err;
130
+ }
131
+ }
132
+ if (!isJson) console.log(` ${label} ...`);
133
+ return fn();
134
+ },
135
+
136
+ note(body, title) {
137
+ if (isJson) return;
138
+ if (interactive) {
139
+ clackNote(body, title);
140
+ return;
141
+ }
142
+ if (title) console.log(`\n${title}`);
143
+ console.log(body);
144
+ },
145
+
146
+ table(rows, columns) {
147
+ if (isJson) return;
148
+ console.log(renderTable(rows, columns));
149
+ },
150
+
151
+ kv(obj, title) {
152
+ if (isJson) return;
153
+ if (title) console.log(color.bold(title));
154
+ console.log(renderKv(obj));
155
+ },
156
+
157
+ log(msg) {
158
+ if (isJson) return;
159
+ console.log(msg);
160
+ },
161
+
162
+ json(payload) {
163
+ // Only stdout write in json mode; pretty-printed, single document.
164
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
165
+ },
166
+
167
+ outro(msg) {
168
+ if (!interactive) return;
169
+ clackOutro(msg);
170
+ },
171
+
172
+ fail(message): never {
173
+ if (isJson) {
174
+ process.stdout.write(`${JSON.stringify({ error: message })}\n`);
175
+ } else if (interactive) {
176
+ cancel(message);
177
+ } else {
178
+ process.stderr.write(`${color.red("error")} ${message}\n`);
179
+ }
180
+ process.exit(1);
181
+ },
182
+ };
183
+ }
184
+
185
+ export { color };
@@ -0,0 +1,17 @@
1
+ import { cancel, isCancel } from "@clack/prompts";
2
+
3
+ /**
4
+ * Guard a clack prompt result. clack returns a cancellation symbol when the
5
+ * user hits Ctrl-C / Esc; this unwraps the value or aborts the whole CLI
6
+ * cleanly (exit 0 — a deliberate cancel, not an error).
7
+ *
8
+ * Re-export clack's `text`/`select`/`confirm`/`multiselect` from
9
+ * `@clack/prompts` directly in command files; wrap each call in `bail()`.
10
+ */
11
+ export function bail<T>(value: T | symbol): T {
12
+ if (isCancel(value)) {
13
+ cancel("Cancelled.");
14
+ process.exit(0);
15
+ }
16
+ return value as T;
17
+ }