diff --git a/docs/refactor/plan.md b/docs/refactor/plan.md new file mode 100644 index 000000000..21d2b8914 --- /dev/null +++ b/docs/refactor/plan.md @@ -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 (dry‑run) 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. diff --git a/src/cli/program.ts b/src/cli/program.ts index 05aec99c5..5e63673da 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -59,12 +59,14 @@ export function buildProgram() { .option("-w, --wait ", "Wait for delivery status (0 to skip)", "20") .option("-p, --poll ", "Polling interval while waiting", "2") .option("--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) => { diff --git a/src/commands/send.ts b/src/commands/send.ts index eb9b335aa..61e8b81a6 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -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; diff --git a/src/config/config.ts b/src/config/config.ts index 08ed74e57..1b4b6e50a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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 {}; diff --git a/src/index.commands.test.ts b/src/index.commands.test.ts index 32a8fedce..e7b0e0468 100644 --- a/src/index.commands.test.ts +++ b/src/index.commands.test.ts @@ -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" });