mailfully 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,22 @@
1
+ import type { MailfullyResult } from "@mailfully/node";
2
+ /**
3
+ * The API accepted the request and will dispatch nothing — every recipient was
4
+ * suppressed. Deliberately NOT 1: `1` means "the API refused me", and a script
5
+ * distinguishing the two needs them to differ. Deliberately not 0 either, so
6
+ * `mailfully send … && next-step` cannot treat zero delivery as success.
7
+ */
8
+ export declare const EXIT_NOTHING_SENT = 3;
9
+ /** A command-level failure: a user-facing message plus the process exit code. */
10
+ export declare class CommandFailure extends Error {
11
+ readonly exitCode: number;
12
+ constructor(message: string, exitCode?: number);
13
+ }
14
+ /** Raised by context resolution when no API key can be found anywhere. */
15
+ export declare class MissingApiKeyError extends Error {
16
+ constructor();
17
+ }
18
+ /**
19
+ * Collapse an SDK result tuple: return the data, or throw a CommandFailure
20
+ * carrying the rendered API error (exit code 1).
21
+ */
22
+ export declare function unwrap<T>(result: MailfullyResult<T>): T;
@@ -0,0 +1,34 @@
1
+ import { renderError } from "./output.js";
2
+ /**
3
+ * The API accepted the request and will dispatch nothing — every recipient was
4
+ * suppressed. Deliberately NOT 1: `1` means "the API refused me", and a script
5
+ * distinguishing the two needs them to differ. Deliberately not 0 either, so
6
+ * `mailfully send … && next-step` cannot treat zero delivery as success.
7
+ */
8
+ export const EXIT_NOTHING_SENT = 3;
9
+ /** A command-level failure: a user-facing message plus the process exit code. */
10
+ export class CommandFailure extends Error {
11
+ exitCode;
12
+ constructor(message, exitCode = 1) {
13
+ super(message);
14
+ this.name = "CommandFailure";
15
+ this.exitCode = exitCode;
16
+ }
17
+ }
18
+ /** Raised by context resolution when no API key can be found anywhere. */
19
+ export class MissingApiKeyError extends Error {
20
+ constructor() {
21
+ super("No API key found. Run `mailfully login`, set MAILFULLY_API_KEY, or pass --api-key.");
22
+ this.name = "MissingApiKeyError";
23
+ }
24
+ }
25
+ /**
26
+ * Collapse an SDK result tuple: return the data, or throw a CommandFailure
27
+ * carrying the rendered API error (exit code 1).
28
+ */
29
+ export function unwrap(result) {
30
+ if (result.error !== null) {
31
+ throw new CommandFailure(renderError(result.error));
32
+ }
33
+ return result.data;
34
+ }
@@ -0,0 +1,3 @@
1
+ import type { Command } from "commander";
2
+ /** Add the three per-command global flags to a leaf command. */
3
+ export declare function withGlobalOptions(cmd: Command): Command;
package/dist/flags.js ADDED
@@ -0,0 +1,7 @@
1
+ /** Add the three per-command global flags to a leaf command. */
2
+ export function withGlobalOptions(cmd) {
3
+ return cmd
4
+ .option("--api-key <key>", "API key (overrides MAILFULLY_API_KEY and the stored config).")
5
+ .option("--api-url <url>", "API base URL (overrides MAILFULLY_API_URL and the stored config).")
6
+ .option("--json", "Print the raw API response as JSON.");
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { MailfullyError } from "@mailfully/node";
2
+ /**
3
+ * Render a plain-text table: header row + data rows, columns padded to the
4
+ * widest cell, two spaces between columns, no trailing whitespace, trailing
5
+ * newline. No colors, no borders — grep/cut friendly.
6
+ */
7
+ export declare function formatTable(header: string[], rows: string[][]): string;
8
+ /** Pretty-print a value as JSON with a trailing newline (for --json mode). */
9
+ export declare function toJson(value: unknown): string;
10
+ /**
11
+ * One human-readable block for an SDK error: type + status + message, the
12
+ * offending param when present, and a hint for the classic self-serve fixes.
13
+ * No trailing newline (the caller appends it when writing).
14
+ */
15
+ export declare function renderError(error: MailfullyError): string;
package/dist/output.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `error.type` values that mean the credential itself is the problem — as
3
+ * opposed to a 403 that is refusing something else about the request
4
+ * (e.g. `domain_limit_reached`, also a 403, which a fresh key would not fix).
5
+ * This allowlist gates the 403 arm of the hint ONLY: a 401 is unconditional,
6
+ * because the status by itself already says the credential was not accepted.
7
+ */
8
+ const AUTH_ERROR_TYPES = new Set([
9
+ "missing_api_key",
10
+ "invalid_api_key",
11
+ "invalid_session_token",
12
+ "insufficient_scope",
13
+ ]);
14
+ /**
15
+ * Render a plain-text table: header row + data rows, columns padded to the
16
+ * widest cell, two spaces between columns, no trailing whitespace, trailing
17
+ * newline. No colors, no borders — grep/cut friendly.
18
+ */
19
+ export function formatTable(header, rows) {
20
+ const all = [header, ...rows];
21
+ const widths = header.map((_, col) => Math.max(...all.map((row) => (row[col] ?? "").length)));
22
+ return (all
23
+ .map((row) => row
24
+ .map((cell, col) => cell.padEnd(widths[col] ?? 0))
25
+ .join(" ")
26
+ .trimEnd())
27
+ .join("\n") + "\n");
28
+ }
29
+ /** Pretty-print a value as JSON with a trailing newline (for --json mode). */
30
+ export function toJson(value) {
31
+ return `${JSON.stringify(value, null, 2)}\n`;
32
+ }
33
+ /**
34
+ * One human-readable block for an SDK error: type + status + message, the
35
+ * offending param when present, and a hint for the classic self-serve fixes.
36
+ * No trailing newline (the caller appends it when writing).
37
+ */
38
+ export function renderError(error) {
39
+ const status = error.statusCode === null ? "network" : `HTTP ${error.statusCode}`;
40
+ const lines = [
41
+ `Error (${error.type ?? "error"}, ${status}): ${error.message}`,
42
+ ];
43
+ if (error.param !== null) {
44
+ lines.push(` param: ${error.param}`);
45
+ }
46
+ // 401 ALWAYS hints: the status alone means the credential was not accepted,
47
+ // and a 401 whose envelope carries no `type` (a proxy or gateway answering
48
+ // ahead of the API) is exactly the case a user most needs the hint for. Only
49
+ // 403 needs the allowlist, because a 403 can be refusing something else about
50
+ // the request that a fresh key would not fix.
51
+ if (error.statusCode === 401 ||
52
+ (error.statusCode === 403 &&
53
+ error.type !== null &&
54
+ AUTH_ERROR_TYPES.has(error.type))) {
55
+ lines.push(" Hint: check the API key and its scopes — run `mailfully login` with a fresh key from the dashboard.");
56
+ }
57
+ else if (error.statusCode === 429) {
58
+ // Six distinct 429s reach here and only ONE is cleared by waiting: the
59
+ // per-second limiter. The rest are the `SEND_REFUSAL_REASONS` family
60
+ // (`packages/db/src/schema/sendRefusals.ts`) — allowances, caps, and the
61
+ // admission hold. The retry advice is therefore an ALLOWLIST of one rather
62
+ // than a copy of the other five: the CLI cannot import that enum
63
+ // (`@mailfully/db` is private and pulls drizzle + ioredis), so a sixth code
64
+ // added there inherits whichever branch is the default. Defaulting to "not
65
+ // retryable" makes that drift harmless instead of actively misleading.
66
+ //
67
+ // A TYPELESS 429 is the deliberate exception, and the mirror of the 401 arm
68
+ // above: no envelope means a proxy or gateway answered ahead of the API,
69
+ // and that one really is a rate limiter.
70
+ if (error.type === "account_under_review") {
71
+ // Deliberately does NOT restate the server's own message (already
72
+ // printed verbatim above) or its 24-hour figure, which is owned by
73
+ // `APPROVAL_QUEUE_SLA_HOURS`. It adds only what the envelope omits.
74
+ lines.push(" Hint: retrying will not clear this — the account is waiting on a human review. Test-mode keys (`mf_test_`) keep working meanwhile.");
75
+ }
76
+ else if (error.type === null || error.type === "rate_limit_exceeded") {
77
+ lines.push(" Hint: rate limited — wait a moment and retry.");
78
+ }
79
+ else {
80
+ lines.push(" Hint: a send quota or cap, not the per-second rate limit — retrying will not clear it until the window resets or the limit is raised in the dashboard.");
81
+ }
82
+ }
83
+ return lines.join("\n");
84
+ }
@@ -0,0 +1,42 @@
1
+ import type { MailfullyResult, Page } from "@mailfully/node";
2
+ /** Injectable delay so tests run instantly (see ProgramDeps.sleep). */
3
+ export type Sleep = (ms: number) => Promise<void>;
4
+ export declare const defaultSleep: Sleep;
5
+ /**
6
+ * Walk every page of a keyset-paginated endpoint (--all). Two behaviors
7
+ * single-page calls don't need:
8
+ * - a 429 waits RETRY_DELAY_MS and retries (up to MAX_RETRIES_PER_PAGE per
9
+ * page) — a bulk walk is exactly the client that trips the rate limiter;
10
+ * - a terminal failure mid-walk names the last good cursor in its message,
11
+ * so a long walk is resumable rather than all-or-nothing.
12
+ *
13
+ * `initialCursor` seeds the walk (from `--cursor`, when the caller passed
14
+ * one) — omit it to start from page one.
15
+ */
16
+ export declare function walkPages<T>(fetchPage: (cursor: string | undefined) => Promise<MailfullyResult<Page<T>>>, sleep: Sleep, initialCursor?: string): Promise<T[]>;
17
+ /**
18
+ * Shared driver for a paginated `list` command (`--limit`/`--cursor`/`--all`),
19
+ * so every list renders and pages identically. `--all` walks every page via
20
+ * {@link walkPages} and `--all --json` prints a synthesized `{ data: [...] }`
21
+ * (there is no single wire page to echo); a single-page `--json` echoes the
22
+ * raw page, and a single-page table is followed by a next-cursor hint.
23
+ */
24
+ export declare function renderPagedList<T>(args: {
25
+ all: boolean | undefined;
26
+ cursor: string | undefined;
27
+ limit: string | undefined;
28
+ json: boolean;
29
+ header: string[];
30
+ toCells: (item: T) => string[];
31
+ fetchPage: (paging: {
32
+ cursor?: string;
33
+ limit?: number;
34
+ }) => Promise<MailfullyResult<Page<T>>>;
35
+ stdout: {
36
+ write(chunk: string): unknown;
37
+ };
38
+ stderr: {
39
+ write(chunk: string): unknown;
40
+ };
41
+ sleep: Sleep;
42
+ }): Promise<void>;
package/dist/pages.js ADDED
@@ -0,0 +1,76 @@
1
+ import { CommandFailure, unwrap } from "./failure.js";
2
+ import { formatTable, renderError, toJson } from "./output.js";
3
+ export const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ const MAX_RETRIES_PER_PAGE = 5;
5
+ const RETRY_DELAY_MS = 2000;
6
+ /**
7
+ * Walk every page of a keyset-paginated endpoint (--all). Two behaviors
8
+ * single-page calls don't need:
9
+ * - a 429 waits RETRY_DELAY_MS and retries (up to MAX_RETRIES_PER_PAGE per
10
+ * page) — a bulk walk is exactly the client that trips the rate limiter;
11
+ * - a terminal failure mid-walk names the last good cursor in its message,
12
+ * so a long walk is resumable rather than all-or-nothing.
13
+ *
14
+ * `initialCursor` seeds the walk (from `--cursor`, when the caller passed
15
+ * one) — omit it to start from page one.
16
+ */
17
+ export async function walkPages(fetchPage, sleep, initialCursor) {
18
+ const rows = [];
19
+ let cursor = initialCursor;
20
+ for (;;) {
21
+ let page;
22
+ let attempt = 0;
23
+ for (;;) {
24
+ const result = await fetchPage(cursor);
25
+ if (result.error === null) {
26
+ page = result.data;
27
+ break;
28
+ }
29
+ if (result.error.statusCode === 429 && attempt < MAX_RETRIES_PER_PAGE) {
30
+ attempt += 1;
31
+ await sleep(RETRY_DELAY_MS);
32
+ continue;
33
+ }
34
+ const resume = cursor === undefined
35
+ ? ""
36
+ : `\n Rows fetched so far were discarded — re-run with --all --cursor ${cursor} to resume.`;
37
+ throw new CommandFailure(`${renderError(result.error)}${resume}`);
38
+ }
39
+ rows.push(...page.data);
40
+ if (page.next_cursor === null)
41
+ return rows;
42
+ cursor = page.next_cursor;
43
+ }
44
+ }
45
+ /**
46
+ * Shared driver for a paginated `list` command (`--limit`/`--cursor`/`--all`),
47
+ * so every list renders and pages identically. `--all` walks every page via
48
+ * {@link walkPages} and `--all --json` prints a synthesized `{ data: [...] }`
49
+ * (there is no single wire page to echo); a single-page `--json` echoes the
50
+ * raw page, and a single-page table is followed by a next-cursor hint.
51
+ */
52
+ export async function renderPagedList(args) {
53
+ const { header, toCells, fetchPage } = args;
54
+ if (args.all === true) {
55
+ const rows = await walkPages((cursor) => fetchPage({ cursor, limit: 100 }), args.sleep, args.cursor);
56
+ if (args.json) {
57
+ args.stdout.write(toJson({ data: rows }));
58
+ return;
59
+ }
60
+ args.stdout.write(formatTable(header, rows.map(toCells)));
61
+ return;
62
+ }
63
+ const page = unwrap(await fetchPage({
64
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
65
+ ...(args.limit !== undefined ? { limit: Number(args.limit) } : {}),
66
+ }));
67
+ if (args.json) {
68
+ args.stdout.write(toJson(page));
69
+ return;
70
+ }
71
+ args.stdout.write(formatTable(header, page.data.map(toCells)));
72
+ if (page.has_more && page.next_cursor !== null) {
73
+ // Hint goes to stderr: stdout carries only data, so pipelines stay clean.
74
+ args.stderr.write(`More results: rerun with --cursor ${page.next_cursor}\n`);
75
+ }
76
+ }
@@ -0,0 +1,37 @@
1
+ import type { MessageType, SendEmailInput, Tag } from "@mailfully/node";
2
+ /** commander collector: repeatable string option → string[]. */
3
+ export declare function collectString(value: string, previous: string[] | undefined): string[];
4
+ /**
5
+ * commander collector: repeatable `name=value` option → record. Splits on the
6
+ * FIRST `=` so values may contain `=`. An empty name is a usage error
7
+ * (InvalidArgumentError → commander usage failure → exit 2).
8
+ */
9
+ export declare function collectPair(value: string, previous: Record<string, string> | undefined): Record<string, string>;
10
+ /**
11
+ * commander parser: validate `--type` against the wire `MessageType` enum.
12
+ * An unrecognized value is a usage error (InvalidArgumentError → commander
13
+ * usage failure → exit 2), matching {@link collectPair}'s treatment of a
14
+ * malformed `--tag`/`--var`/`--header` value.
15
+ */
16
+ export declare function parseMessageType(value: string): MessageType;
17
+ /** `--tag` pairs → the SDK's Tag[] shape. */
18
+ export declare function pairsToTags(pairs: Record<string, string>): Tag[];
19
+ /**
20
+ * Resolve a body that may come inline (`--html "<p>x</p>"`) or from a file
21
+ * (`--html-file body.html`). Both at once is a usage error (exit 2); an
22
+ * unreadable file is a runtime failure (exit 1).
23
+ */
24
+ export declare function readBody(inline: string | undefined, file: string | undefined, flag: string): Promise<string | undefined>;
25
+ /**
26
+ * Read and JSON-parse a file argument. An unreadable file is a runtime
27
+ * failure (exit 1); malformed JSON is a usage error (exit 2) — the file's
28
+ * contents are the user's input.
29
+ */
30
+ export declare function readJsonFile(path: string): Promise<unknown>;
31
+ /**
32
+ * Map one wire-shape (snake_case) email object from a batch file to the SDK's
33
+ * camelCase {@link SendEmailInput}. Only two keys differ (`reply_to`,
34
+ * `scheduled_at`); everything else passes through untouched — the API is the
35
+ * validator, the CLI is a faithful courier.
36
+ */
37
+ export declare function wireToSendInput(raw: Record<string, unknown>): SendEmailInput;
package/dist/parse.js ADDED
@@ -0,0 +1,92 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { InvalidArgumentError } from "commander";
3
+ import { CommandFailure } from "./failure.js";
4
+ /** commander collector: repeatable string option → string[]. */
5
+ export function collectString(value, previous) {
6
+ return [...(previous ?? []), value];
7
+ }
8
+ /**
9
+ * commander collector: repeatable `name=value` option → record. Splits on the
10
+ * FIRST `=` so values may contain `=`. An empty name is a usage error
11
+ * (InvalidArgumentError → commander usage failure → exit 2).
12
+ */
13
+ export function collectPair(value, previous) {
14
+ const eq = value.indexOf("=");
15
+ if (eq <= 0) {
16
+ throw new InvalidArgumentError("Expected name=value.");
17
+ }
18
+ return { ...(previous ?? {}), [value.slice(0, eq)]: value.slice(eq + 1) };
19
+ }
20
+ /**
21
+ * commander parser: validate `--type` against the wire `MessageType` enum.
22
+ * An unrecognized value is a usage error (InvalidArgumentError → commander
23
+ * usage failure → exit 2), matching {@link collectPair}'s treatment of a
24
+ * malformed `--tag`/`--var`/`--header` value.
25
+ */
26
+ export function parseMessageType(value) {
27
+ if (value !== "transactional" && value !== "marketing") {
28
+ throw new InvalidArgumentError(`Expected "transactional" or "marketing", got "${value}".`);
29
+ }
30
+ return value;
31
+ }
32
+ /** `--tag` pairs → the SDK's Tag[] shape. */
33
+ export function pairsToTags(pairs) {
34
+ return Object.entries(pairs).map(([name, value]) => ({ name, value }));
35
+ }
36
+ /**
37
+ * Resolve a body that may come inline (`--html "<p>x</p>"`) or from a file
38
+ * (`--html-file body.html`). Both at once is a usage error (exit 2); an
39
+ * unreadable file is a runtime failure (exit 1).
40
+ */
41
+ export async function readBody(inline, file, flag) {
42
+ if (inline !== undefined && file !== undefined) {
43
+ throw new CommandFailure(`Use ${flag} or ${flag}-file, not both.`, 2);
44
+ }
45
+ if (file !== undefined) {
46
+ try {
47
+ return await readFile(file, "utf8");
48
+ }
49
+ catch {
50
+ throw new CommandFailure(`Could not read ${file}.`);
51
+ }
52
+ }
53
+ return inline;
54
+ }
55
+ /**
56
+ * Read and JSON-parse a file argument. An unreadable file is a runtime
57
+ * failure (exit 1); malformed JSON is a usage error (exit 2) — the file's
58
+ * contents are the user's input.
59
+ */
60
+ export async function readJsonFile(path) {
61
+ let raw;
62
+ try {
63
+ raw = await readFile(path, "utf8");
64
+ }
65
+ catch {
66
+ throw new CommandFailure(`Could not read ${path}.`);
67
+ }
68
+ try {
69
+ return JSON.parse(raw);
70
+ }
71
+ catch {
72
+ throw new CommandFailure(`${path} is not valid JSON.`, 2);
73
+ }
74
+ }
75
+ /**
76
+ * Map one wire-shape (snake_case) email object from a batch file to the SDK's
77
+ * camelCase {@link SendEmailInput}. Only two keys differ (`reply_to`,
78
+ * `scheduled_at`); everything else passes through untouched — the API is the
79
+ * validator, the CLI is a faithful courier.
80
+ */
81
+ export function wireToSendInput(raw) {
82
+ const { reply_to, scheduled_at, ...rest } = raw;
83
+ return {
84
+ ...rest,
85
+ ...(reply_to !== undefined
86
+ ? { replyTo: reply_to }
87
+ : {}),
88
+ ...(scheduled_at !== undefined
89
+ ? { scheduledAt: scheduled_at }
90
+ : {}),
91
+ };
92
+ }
@@ -0,0 +1,35 @@
1
+ import { Command } from "commander";
2
+ /** Everything the program touches in the outside world, injected for tests. */
3
+ export interface ProgramDeps {
4
+ env: Record<string, string | undefined>;
5
+ stdout: {
6
+ write(chunk: string): unknown;
7
+ };
8
+ stderr: {
9
+ write(chunk: string): unknown;
10
+ };
11
+ /** Injected into the SDK; defaults to global fetch when omitted. */
12
+ fetchImpl?: typeof fetch;
13
+ /** Config-file location (tests point this at a temp file). */
14
+ configPath: string;
15
+ /** Injected secret prompt; defaults to the readline prompt (Task 8). */
16
+ promptSecret?: (promptText: string) => Promise<string>;
17
+ /** Injected delay for the --all 429 backoff (Task 12); tests pass an instant one. */
18
+ sleep?: (ms: number) => Promise<void>;
19
+ }
20
+ /**
21
+ * Build the full command tree. Output is routed through the injected streams
22
+ * and exits are overridden, so the program is a pure function of (argv, deps).
23
+ *
24
+ * NOTE: `.configureOutput` and `.exitOverride` MUST be set before any
25
+ * `.command(...)` call — commander copies inherited settings into subcommands
26
+ * at creation time.
27
+ */
28
+ export declare function buildProgram(deps: ProgramDeps): Command;
29
+ /**
30
+ * Parse argv and translate every outcome into an exit code:
31
+ * 0 — success, help, or version
32
+ * 1 — API/transport/config failure (CommandFailure, MissingApiKeyError)
33
+ * 2 — usage error (unknown command/option, bad argument)
34
+ */
35
+ export declare function runCli(argv: string[], deps: ProgramDeps): Promise<number>;
@@ -0,0 +1,83 @@
1
+ import { Command, CommanderError } from "commander";
2
+ import { registerAnalyticsCommands } from "./commands/analytics.js";
3
+ import { registerAuthCommands } from "./commands/auth.js";
4
+ import { registerDomainsCommands } from "./commands/domains.js";
5
+ import { registerEmailsCommands } from "./commands/emails.js";
6
+ import { registerSuppressionsCommands } from "./commands/suppressions.js";
7
+ import { registerTemplatesCommands } from "./commands/templates.js";
8
+ import { CommandFailure, MissingApiKeyError } from "./failure.js";
9
+ import { cliVersion } from "./version.js";
10
+ /**
11
+ * Build the full command tree. Output is routed through the injected streams
12
+ * and exits are overridden, so the program is a pure function of (argv, deps).
13
+ *
14
+ * NOTE: `.configureOutput` and `.exitOverride` MUST be set before any
15
+ * `.command(...)` call — commander copies inherited settings into subcommands
16
+ * at creation time.
17
+ */
18
+ export function buildProgram(deps) {
19
+ const program = new Command("mailfully");
20
+ program
21
+ .description("The Mailfully command-line interface.")
22
+ .version(cliVersion(), "--version", "Print the CLI version.")
23
+ .configureOutput({
24
+ writeOut: (str) => void deps.stdout.write(str),
25
+ writeErr: (str) => void deps.stderr.write(str),
26
+ })
27
+ .exitOverride();
28
+ registerAuthCommands(program, deps);
29
+ registerEmailsCommands(program, deps);
30
+ registerDomainsCommands(program, deps);
31
+ registerTemplatesCommands(program, deps);
32
+ registerSuppressionsCommands(program, deps);
33
+ registerAnalyticsCommands(program, deps);
34
+ return program;
35
+ }
36
+ /**
37
+ * Parse argv and translate every outcome into an exit code:
38
+ * 0 — success, help, or version
39
+ * 1 — API/transport/config failure (CommandFailure, MissingApiKeyError)
40
+ * 2 — usage error (unknown command/option, bad argument)
41
+ */
42
+ export async function runCli(argv, deps) {
43
+ const program = buildProgram(deps);
44
+ if (argv.length === 0) {
45
+ program.outputHelp();
46
+ return 0;
47
+ }
48
+ try {
49
+ await program.parseAsync(argv, { from: "user" });
50
+ return 0;
51
+ }
52
+ catch (err) {
53
+ if (err instanceof CommandFailure) {
54
+ // An EMPTY message means the command already wrote its own explanation
55
+ // (or is in --json mode, where stderr must stay clean) and is throwing
56
+ // only to carry the exit code out.
57
+ if (err.message !== "") {
58
+ deps.stderr.write(`${err.message}\n`);
59
+ }
60
+ return err.exitCode;
61
+ }
62
+ if (err instanceof MissingApiKeyError) {
63
+ deps.stderr.write(`${err.message}\n`);
64
+ return 1;
65
+ }
66
+ if (err instanceof CommanderError) {
67
+ if (err.code === "commander.helpDisplayed" ||
68
+ err.code === "commander.version") {
69
+ return 0;
70
+ }
71
+ if (err.code === "commander.help") {
72
+ // Thrown for both `mailfully help` (exitCode 0) and a bare group
73
+ // command like `mailfully emails` (help({ error: true }), exitCode 1
74
+ // — a usage mistake). Preserve the 0/2 contract accordingly.
75
+ return err.exitCode === 0 ? 0 : 2;
76
+ }
77
+ // commander already wrote the usage message via configureOutput.
78
+ return 2;
79
+ }
80
+ deps.stderr.write(`Unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
81
+ return 1;
82
+ }
83
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Prompt for a secret on the controlling terminal without echoing it: the
3
+ * prompt text goes to stderr (stdout stays clean for piping) and the readline
4
+ * interface is given NO output stream, so typed characters are never echoed.
5
+ * When stdin is not a TTY (e.g. `mailfully login < keyfile`), reads one line.
6
+ *
7
+ * EOF safety: on a closed/empty stdin (CI with no input, Ctrl+D) the question
8
+ * callback never fires — the `close` handler resolves with "" so the caller's
9
+ * empty-key check turns it into a clean usage error instead of a hang. Ctrl+C
10
+ * at the prompt maps to the same path.
11
+ */
12
+ export declare function promptSecret(promptText: string): Promise<string>;
package/dist/prompt.js ADDED
@@ -0,0 +1,37 @@
1
+ import { createInterface } from "node:readline";
2
+ /**
3
+ * Prompt for a secret on the controlling terminal without echoing it: the
4
+ * prompt text goes to stderr (stdout stays clean for piping) and the readline
5
+ * interface is given NO output stream, so typed characters are never echoed.
6
+ * When stdin is not a TTY (e.g. `mailfully login < keyfile`), reads one line.
7
+ *
8
+ * EOF safety: on a closed/empty stdin (CI with no input, Ctrl+D) the question
9
+ * callback never fires — the `close` handler resolves with "" so the caller's
10
+ * empty-key check turns it into a clean usage error instead of a hang. Ctrl+C
11
+ * at the prompt maps to the same path.
12
+ */
13
+ export function promptSecret(promptText) {
14
+ return new Promise((resolve) => {
15
+ let settled = false;
16
+ const settle = (value) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ process.stderr.write("\n");
21
+ resolve(value.trim());
22
+ };
23
+ process.stderr.write(promptText);
24
+ const rl = createInterface({
25
+ input: process.stdin,
26
+ terminal: process.stdin.isTTY === true,
27
+ });
28
+ rl.on("close", () => settle(""));
29
+ rl.on("SIGINT", () => rl.close());
30
+ rl.question("", (answer) => {
31
+ // settle BEFORE close: rl.close() emits 'close' synchronously, and the
32
+ // close handler's settle("") must lose this race, not win it.
33
+ settle(answer);
34
+ rl.close();
35
+ });
36
+ });
37
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The CLI's own version, read from package.json at runtime via createRequire —
3
+ * works identically from src (tests) and from the published dist layout, since
4
+ * both sit one directory below the package root.
5
+ */
6
+ export declare function cliVersion(): string;
@@ -0,0 +1,11 @@
1
+ import { createRequire } from "node:module";
2
+ /**
3
+ * The CLI's own version, read from package.json at runtime via createRequire —
4
+ * works identically from src (tests) and from the published dist layout, since
5
+ * both sit one directory below the package root.
6
+ */
7
+ export function cliVersion() {
8
+ const require = createRequire(import.meta.url);
9
+ const pkg = require("../package.json");
10
+ return pkg.version;
11
+ }