feat: add config validation and send dry-run

This commit is contained in:
Peter Steinberger
2025-11-25 03:46:26 +01:00
parent a89d7319a9
commit 8bd406f6b1
5 changed files with 116 additions and 1 deletions

28
docs/refactor/plan.md Normal file
View File

@@ -0,0 +1,28 @@
# Refactor Roadmap (2025-11-25)
This is a living note capturing the cleanups underway to keep `warelay` small and maintainable.
## Goals
- Keep `src/index.ts` thin (<150 LOC) and treat it purely as the CLI entry/export surface.
- Encapsulate infra helpers (ports, binaries, tailscale) and provider-specific code behind small modules.
- Harden configuration validation and logging so users get actionable errors.
- Improve UX for experimenting (dryrun) without hitting Twilio or WhatsApp.
## Completed
- Extracted infra helpers into `src/infra/{ports,binaries,tailscale}.ts`.
- Moved CLI dependency wiring into `src/cli/deps.ts`; `monitorWebProvider` now lives in `provider-web.ts`.
- Added prompt/wait helpers (`src/cli/{prompt,wait}.ts`) and Twilio sender discovery module (`src/twilio/senders.ts`).
- Slimmed `src/index.ts` to ~130 LOC.
- README updated to document direct WhatsApp Web support and Claude output handling.
## In this pass
- Added config validation for inbound reply settings (claude output format, command/text shape).
- Added `--dry-run` for `send` to print the outbound payload without contacting providers.
- Documented roadmap (this file).
## Next candidates (not yet done)
- Centralize logging/verbosity (runtime-aware logger wrapper).
- Provider barrels (`src/providers/twilio`, `src/providers/web`) to isolate imports further.
- Webhook module grouping (`src/webhook/*`) to house server + Twilio update helpers.
- Retry/backoff for webhook bring-up and monitor polling.
- More unit tests for infra helpers (`ports`, `tailscale`) and CLI dep wiring.

View File

@@ -59,12 +59,14 @@ export function buildProgram() {
.option("-w, --wait <seconds>", "Wait for delivery status (0 to skip)", "20")
.option("-p, --poll <seconds>", "Polling interval while waiting", "2")
.option("--provider <provider>", "Provider: twilio | web", "twilio")
.option("--dry-run", "Print payload and skip sending", false)
.addHelpText(
"after",
`
Examples:
warelay send --to +15551234567 --message "Hi" # wait 20s for delivery (default)
warelay send --to +15551234567 --message "Hi" --wait 0 # fire-and-forget
warelay send --to +15551234567 --message "Hi" --dry-run # print payload only
warelay send --to +15551234567 --message "Hi" --wait 60 --poll 3`,
)
.action(async (opts) => {

View File

@@ -10,6 +10,7 @@ export async function sendCommand(
wait: string;
poll: string;
provider: Provider;
dryRun?: boolean;
},
deps: CliDeps,
runtime: RuntimeEnv,
@@ -26,6 +27,12 @@ export async function sendCommand(
}
if (opts.provider === "web") {
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via web -> ${opts.to}: ${opts.message}`,
);
return;
}
if (waitSeconds !== 0) {
runtime.log(info("Wait/poll are Twilio-only; ignored for provider=web."));
}
@@ -33,6 +40,13 @@ export async function sendCommand(
return;
}
if (opts.dryRun) {
runtime.log(
`[dry-run] would send via twilio -> ${opts.to}: ${opts.message}`,
);
return;
}
const result = await deps.sendMessage(opts.to, opts.message, runtime);
if (!result) return;
if (waitSeconds === 0) return;

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import { z } from "zod";
export type ReplyMode = "text" | "command";
export type ClaudeOutputFormat = "text" | "json" | "stream-json";
@@ -36,6 +37,50 @@ export type WarelayConfig = {
export const CONFIG_PATH = path.join(os.homedir(), ".warelay", "warelay.json");
const ReplySchema = z
.object({
mode: z.union([z.literal("text"), z.literal("command")]),
text: z.string().optional(),
command: z.array(z.string()).optional(),
template: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),
bodyPrefix: z.string().optional(),
session: z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),
store: z.string().optional(),
sessionArgNew: z.array(z.string()).optional(),
sessionArgResume: z.array(z.string()).optional(),
sessionArgBeforeBody: z.boolean().optional(),
})
.optional(),
claudeOutputFormat: z
.union([
z.literal("text"),
z.literal("json"),
z.literal("stream-json"),
z.undefined(),
])
.optional(),
})
.refine(
(val) => (val.mode === "text" ? Boolean(val.text) : Boolean(val.command)),
{
message: "reply.text is required for mode=text; reply.command is required for mode=command",
},
);
const WarelaySchema = z.object({
inbound: z
.object({
allowFrom: z.array(z.string()).optional(),
reply: ReplySchema.optional(),
})
.optional(),
});
export function loadConfig(): WarelayConfig {
// Read ~/.warelay/warelay.json (JSON5) if present.
try {
@@ -43,7 +88,13 @@ export function loadConfig(): WarelayConfig {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const parsed = JSON5.parse(raw);
if (typeof parsed !== "object" || parsed === null) return {};
return parsed as WarelayConfig;
const validated = WarelaySchema.safeParse(parsed);
if (!validated.success) {
console.error("Invalid warelay config:");
validated.error.issues.forEach((iss) => console.error(`- ${iss.path.join(".")}: ${iss.message}`));
return {};
}
return validated.data as WarelayConfig;
} catch (err) {
console.error(`Failed to read config at ${CONFIG_PATH}`, err);
return {};

View File

@@ -62,6 +62,26 @@ describe("CLI commands", () => {
expect(wait).not.toHaveBeenCalled();
});
it("send command supports dry-run and skips sending", async () => {
const twilio = (await import("twilio")).default;
const wait = vi.spyOn(index, "waitForFinalStatus").mockResolvedValue();
await index.program.parseAsync(
[
"send",
"--to",
"+1555",
"--message",
"hi",
"--wait",
"0",
"--dry-run",
],
{ from: "user" },
);
expect(twilio._client.messages.create).not.toHaveBeenCalled();
expect(wait).not.toHaveBeenCalled();
});
it("login alias calls web login", async () => {
const spy = vi.spyOn(providerWeb, "loginWeb").mockResolvedValue();
await index.program.parseAsync(["login"], { from: "user" });